chore: 1.5.1 (#387)

* lint(persistence): Apply team linter

Signed-off-by: Sahil Kumar <[email protected]>

* style: flutter format

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): Add tests for channel dao

Signed-off-by: Sahil Kumar <[email protected]>

* refactor(persistence): remove dummy comments

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): Add test for channel query dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for connection event dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for member dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for message dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for pinned message dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for reaction dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for read dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): minor improvement in update function tests

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for user dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for list converter

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for map converter

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for message sending status converter

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for channel mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add test for event mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for member mapper

Signed-off-by: Sahil Kumar <[email protected]>

* remove useless dependencies

* test(persistence): add tests for message mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for pinned message mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for reaction mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for read mapper

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for user mapper

Signed-off-by: Sahil Kumar <[email protected]>

* refactor(persistence): remove redundant UsersTable getter from ConnectionEventDao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests for stream chat persistence client

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add tests moor chat database

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add moor schemas for future migration tests

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add clear queryCache test in channel query dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add getThreadMessagesByParentId along with pagination test in message, pinned message dao.

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add getMessagesByCid along with pagination test in message, pinned message dao.

Signed-off-by: Sahil Kumar <[email protected]>

* update ci

* apply locale to jiffy

* fix test

* test(persistence): add getMessagesByCid along with quotedMessage test in message, pinned message dao.

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add extra checks in getMessagesByCid along with pagination test in message, pinned message dao.

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add return channel with pagination applies test in channel query dao

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): improve message mapper, pinned message mapper test

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add client constructor test in stream chat persistence client

Signed-off-by: Sahil Kumar <[email protected]>

* test(persistence): add coverage:ignore-file for entities and db/shared directory

Signed-off-by: Sahil Kumar <[email protected]>

* skip pub publish --dry-run for develop branch

Signed-off-by: Sahil Kumar <[email protected]>

* add remove from coverage script

Signed-off-by: Sahil Kumar <[email protected]>

* fix

Signed-off-by: Sahil Kumar <[email protected]>

* fix

Signed-off-by: Sahil Kumar <[email protected]>

* fix

Signed-off-by: Sahil Kumar <[email protected]>

* fix

Signed-off-by: Sahil Kumar <[email protected]>

* fix locale

* fix message input _parseExistingMessage

* fix(UI-Kit): Show selected attachment file as file in message input widget

Signed-off-by: Sahil Kumar <[email protected]>

* fix: ws disconnection (#345)

* update ws removing custom implementation; fixed disconnection while connecting

* add test

* feat: Apply team lints to core package (#334)

* Add analyzer file to core package

* Apply team lint rules to core package

* clean up analyzer warnings in stream chat flutter

* remove unused line

* add ignore

* sync analysis options

* [Core -> MessageWidget] Fix variable dynamic types

Signed-off-by: Sahil Kumar <[email protected]>

Co-authored-by: Salvatore Giordano <[email protected]>
Co-authored-by: Sahil Kumar <[email protected]>

* test: add tests for stream chat flutter core (#354)

* test(core): add tests for channels bloc

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): refactor some tests due to changes in channels bloc

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for channel list core

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for users bloc, minor improvements

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for user list core, minor improvements

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for message search bloc, refactor

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for message search list core, refactor

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for lazy load scroll view

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for stream chat core

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for message list core

Signed-off-by: Sahil Kumar <[email protected]>

* test(core): add tests for stream channel

Signed-off-by: Sahil Kumar <[email protected]>

* Fix merge conflicts

Signed-off-by: Sahil Kumar <[email protected]>

* flutter format

Signed-off-by: Sahil Kumar <[email protected]>

* fixes

Signed-off-by: Sahil Kumar <[email protected]>

* update workflow

Signed-off-by: Sahil Kumar <[email protected]>

* chore(core): fix analyzer issues

Signed-off-by: Sahil Kumar <[email protected]>

* chore(ui-kit): fix analyzer issues

Signed-off-by: Sahil Kumar <[email protected]>

* refactor(ui-kit): update giphy interaction (#361)

* refactor(ui-kit): update giphy interaction

Signed-off-by: Sahil Kumar <[email protected]>

* fix overflow

Co-authored-by: Salvatore Giordano <[email protected]>

* fix: Fixed sendingbutton.outside case (#355)

* fix: Fixed sendingbutton.outside case

* fix: Fixed all actionbutton cases

* fix review

Co-authored-by: Salvatore Giordano <[email protected]>

* fix(ui-kit): fix accessibility text size scaling in message widget

Signed-off-by: Sahil Kumar <[email protected]>

* fix(ui-kit): fix message widget long username overflow

Signed-off-by: Sahil Kumar <[email protected]>

* fix(ui-kit): fix message input dm checkbox accessibility scaling issue

Signed-off-by: Sahil Kumar <[email protected]>

* feat: exposed textbuilder callback (#367)

* exposed textbuilder callback

* exposed textbuilder callback

* fixes

Signed-off-by: Sahil Kumar <[email protected]>

* check if user.mutes is null

* check if user.mutes is null

* fixes #370: remove all controls on full screen media if message is of type ephemeral (#373)

* fix: fix channel list pagination spinner (#375)

Signed-off-by: Sahil Kumar <[email protected]>

* fix: attachments upload when size exceeds (#377)

* fix: Fixed file upload error

* fix: Fixed file upload error

* fix coverage script

Signed-off-by: Sahil Kumar <[email protected]>

* fix: message input (#382)

* feat: Added mentions suffix builder

* feat: Added builders for mentions and theme element for message input expanded button

* refactor: refactored PR to suit other style

* fix: fixes trailing commas and changes names

* doc: added documentation to new elements

* doc: added documentation to new elements

* bump version

* add meta dep to core

* sort deps

Co-authored-by: Sahil Kumar <[email protected]>
Co-authored-by: Neevash Ramdial (Nash) <[email protected]>
Co-authored-by: Deven Joshi <[email protected]>
This commit is contained in:
Salvatore Giordano
2021-04-07 12:24:29 +02:00
committed by GitHub
co-authored by Sahil Kumar Neevash Ramdial Deven Joshi
parent aeb6da8d38
commit 87ca818a9a
119 changed files with 11223 additions and 1240 deletions
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Fast fail the script on failures.
set -e
pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$'
+13 -7
View File
@@ -8,6 +8,7 @@ on:
push: push:
branches: branches:
- master - master
- develop
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
@@ -32,6 +33,7 @@ jobs:
melos exec -c 3 --ignore="*example*" -- \ melos exec -c 3 --ignore="*example*" -- \
tuneup check tuneup check
- name: 'Pub Check' - name: 'Pub Check'
if: github.ref == 'refs/heads/master'
run: | run: |
melos exec -c 1 --no-private --ignore="*example*" -- \ melos exec -c 1 --no-private --ignore="*example*" -- \
pub publish --dry-run pub publish --dry-run
@@ -57,7 +59,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
@@ -68,31 +70,35 @@ jobs:
run: | run: |
./.github/workflows/scripts/install-tools.sh ./.github/workflows/scripts/install-tools.sh
flutter pub global activate coverage flutter pub global activate coverage
flutter pub global activate remove_from_coverage
- name: 'Bootstrap Workspace' - name: 'Bootstrap Workspace'
run: melos bootstrap run: melos bootstrap
- name: 'Dart Test' - name: 'Dart Test'
run: | run: |
cd packages/stream_chat cd packages/stream_chat
flutter pub run test --coverage coverage/ flutter pub run test --coverage coverage/
format_coverage --lcov --in=coverage/ --out=lcov.info --packages=.packages --report-on=lib format_coverage --lcov --in=coverage/ --out=coverage/lcov.info --packages=.packages --report-on=lib
- name: 'Flutter Test' - name: 'Flutter Test'
run: | run: |
melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \
flutter test --coverage flutter test --coverage
- name: CodeCov - name: CodeCov
run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} run: |
melos exec -c 3 --fail-fast --dir-exists=test --ignore="*example*" --ignore="*web*" -- \
"\$MELOS_ROOT_PATH/.github/workflows/scripts/coverage.sh"
bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }}
- uses: VeryGoodOpenSource/[email protected] - uses: VeryGoodOpenSource/[email protected]
with: with:
path: packages/stream_chat/lcov.info path: packages/stream_chat/coverage/lcov.info
min_coverage: 50 min_coverage: 40
- uses: VeryGoodOpenSource/[email protected] - uses: VeryGoodOpenSource/[email protected]
with: with:
path: packages/stream_chat_persistence/coverage/lcov.info path: packages/stream_chat_persistence/coverage/lcov.info
min_coverage: 0.2 min_coverage: 95
- uses: VeryGoodOpenSource/[email protected] - uses: VeryGoodOpenSource/[email protected]
with: with:
path: packages/stream_chat_flutter_core/coverage/lcov.info path: packages/stream_chat_flutter_core/coverage/lcov.info
min_coverage: 4.5 min_coverage: 90
- uses: VeryGoodOpenSource/[email protected] - uses: VeryGoodOpenSource/[email protected]
with: with:
path: packages/stream_chat_flutter/coverage/lcov.info path: packages/stream_chat_flutter/coverage/lcov.info
+4
View File
@@ -1,3 +1,7 @@
## 1.5.1
- Minor fixes and improvements
## 1.5.0 ## 1.5.0
- Minor fixes and improvements - Minor fixes and improvements
@@ -14,7 +14,6 @@ linter:
- cancel_subscriptions - cancel_subscriptions
- close_sinks - close_sinks
- control_flow_in_finally - control_flow_in_finally
- diagnostic_describe_all_properties
- empty_statements - empty_statements
- hash_and_equals - hash_and_equals
- invariant_booleans - invariant_booleans
@@ -33,7 +32,6 @@ linter:
- use_key_in_widget_constructors - use_key_in_widget_constructors
- valid_regexps - valid_regexps
- always_declare_return_types - always_declare_return_types
- always_put_required_named_parameters_first
- always_require_non_null_named_parameters - always_require_non_null_named_parameters
- annotate_overrides - annotate_overrides
- avoid_bool_literals_in_conditional_expressions - avoid_bool_literals_in_conditional_expressions
@@ -146,7 +146,7 @@ class Channel {
/// Channel extra data /// Channel extra data
Map<String, dynamic> get extraData => Map<String, dynamic> get extraData =>
state?._channelState?.channel?.extraData; state?._channelState?.channel?.extraData ?? _extraData;
/// Channel extra data as a stream /// Channel extra data as a stream
Stream<Map<String, dynamic>> get extraDataStream => Stream<Map<String, dynamic>> get extraDataStream =>
@@ -1210,9 +1210,10 @@ class ChannelClientState {
this._channel, this._channel,
ChannelState channelState, ChannelState channelState,
//ignore: unnecessary_parenthesis //ignore: unnecessary_parenthesis
) : _debouncedUpdatePersistenceChannelState = ((ChannelState state) { ) : _debouncedUpdatePersistenceChannelState = ((ChannelState state) =>
_channel?._client?.chatPersistenceClient?.updateChannelState(state); _channel?._client?.chatPersistenceClient
}).debounced(const Duration(seconds: 1)) { ?.updateChannelState(state))
.debounced(const Duration(seconds: 1)) {
retryQueue = RetryQueue( retryQueue = RetryQueue(
channel: _channel, channel: _channel,
logger: Logger('RETRY QUEUE ${_channel.cid}'), logger: Logger('RETRY QUEUE ${_channel.cid}'),
@@ -1597,7 +1598,7 @@ class ChannelClientState {
bool _countMessageAsUnread(Message message) { bool _countMessageAsUnread(Message message) {
final userId = _channel.client.state?.user?.id; final userId = _channel.client.state?.user?.id;
final userIsMuted = _channel.client.state.user.mutes.firstWhere( final userIsMuted = _channel.client.state?.user?.mutes?.firstWhere(
(m) => m.user?.id == message.user.id, (m) => m.user?.id == message.user.id,
orElse: () => null, orElse: () => null,
) != ) !=
@@ -104,4 +104,25 @@ class PaginationParams {
lessThan: lessThan ?? this.lessThan, lessThan: lessThan ?? this.lessThan,
lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual, lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual,
); );
@override
int get hashCode =>
runtimeType.hashCode ^
limit.hashCode ^
offset.hashCode ^
greaterThan.hashCode ^
greaterThanOrEqual.hashCode ^
lessThan.hashCode ^
lessThanOrEqual.hashCode;
@override
bool operator ==(covariant PaginationParams other) =>
identical(this, other) ||
runtimeType == other.runtimeType &&
limit == other.limit &&
offset == other.offset &&
greaterThan == other.greaterThan &&
greaterThanOrEqual == other.greaterThanOrEqual &&
lessThan == other.lessThan &&
lessThanOrEqual == other.lessThanOrEqual;
} }
@@ -6,9 +6,6 @@ import 'package:logging/logging.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/connection_status.dart'; import 'package:stream_chat/src/api/connection_status.dart';
import 'package:stream_chat/src/api/web_socket_channel_stub.dart'
if (dart.library.html) 'web_socket_channel_html.dart'
if (dart.library.io) 'web_socket_channel_io.dart';
import 'package:stream_chat/src/models/event.dart'; import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/src/models/user.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
@@ -36,7 +33,7 @@ class WebSocket {
this.connectPayload, this.connectPayload,
this.handler, this.handler,
this.logger, this.logger,
this.connectFunc = connectWebSocket, this.connectFunc,
this.reconnectionMonitorInterval = 1, this.reconnectionMonitorInterval = 1,
this.healthCheckInterval = 20, this.healthCheckInterval = 20,
this.reconnectionMonitorTimeout = 40, this.reconnectionMonitorTimeout = 40,
@@ -121,9 +118,9 @@ class WebSocket {
WebSocketChannel _channel; WebSocketChannel _channel;
Timer _healthCheck, _reconnectionMonitor; Timer _healthCheck, _reconnectionMonitor;
DateTime _lastEventAt; DateTime _lastEventAt;
bool _manuallyDisconnected = false, bool _manuallyDisconnected = false;
_connecting = false, bool _connecting = false;
_reconnecting = false; bool _reconnecting = false;
Event _decodeEvent(String source) => Event.fromJson(json.decode(source)); Event _decodeEvent(String source) => Event.fromJson(json.decode(source));
@@ -143,7 +140,8 @@ class WebSocket {
logger.info('connecting to $_path'); logger.info('connecting to $_path');
_channel = connectFunc(_path); _channel =
connectFunc?.call(_path) ?? WebSocketChannel.connect(Uri.parse(_path));
_channel.stream.listen( _channel.stream.listen(
(data) { (data) {
final jsonData = json.decode(data); final jsonData = json.decode(data);
@@ -177,6 +175,10 @@ class WebSocket {
} }
void _onData(data) { void _onData(data) {
if (_manuallyDisconnected) {
return;
}
final event = _decodeEvent(data); final event = _decodeEvent(data);
logger.info('received new event: $data'); logger.info('received new event: $data');
@@ -302,6 +304,10 @@ class WebSocket {
/// Disconnects the WS and releases eventual resources /// Disconnects the WS and releases eventual resources
Future<void> disconnect() async { Future<void> disconnect() async {
_connecting = false;
if (!_connectionCompleter.isCompleted) {
_connectionCompleter.complete();
}
if (_manuallyDisconnected) { if (_manuallyDisconnected) {
return; return;
} }
+1
View File
@@ -738,6 +738,7 @@ class StreamChatClient {
await _chatPersistenceClient?.updateChannelQueries( await _chatPersistenceClient?.updateChannelQueries(
filter, filter,
channels.map((c) => c.channel.cid).toList(), channels.map((c) => c.channel.cid).toList(),
clearQueryCache:
paginationParams?.offset == null || paginationParams.offset == 0, paginationParams?.offset == null || paginationParams.offset == 0,
); );
@@ -100,10 +100,9 @@ abstract class ChatPersistenceClient {
/// the list of matching rows will be deleted /// the list of matching rows will be deleted
Future<void> updateChannelQueries( Future<void> updateChannelQueries(
Map<String, dynamic> filter, Map<String, dynamic> filter,
List<String> cids, List<String> cids, {
// ignore: avoid_positional_boolean_parameters bool clearQueryCache = false,
bool clearQueryCache, });
);
/// Remove a message by [messageId] /// Remove a message by [messageId]
Future<void> deleteMessageById(String messageId) => Future<void> deleteMessageById(String messageId) =>
@@ -121,6 +121,46 @@ class Event {
_$EventToJson(this), _$EventToJson(this),
topLevelFields, topLevelFields,
); );
/// Creates a copy of [Event] with specified attributes overridden.
Event copyWith({
String type,
String cid,
String channelId,
String channelType,
String connectionId,
DateTime createdAt,
OwnUser me,
User user,
Message message,
EventChannel channel,
Member member,
Reaction reaction,
int totalUnreadCount,
int unreadChannels,
bool online,
String parentId,
Map<String, dynamic> extraData,
}) =>
Event(
type: type ?? this.type,
cid: cid ?? this.cid,
connectionId: connectionId ?? this.connectionId,
createdAt: createdAt ?? this.createdAt,
me: me ?? this.me,
user: user ?? this.user,
message: message ?? this.message,
totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount,
unreadChannels: unreadChannels ?? this.unreadChannels,
reaction: reaction ?? this.reaction,
online: online ?? this.online,
channel: channel ?? this.channel,
member: member ?? this.member,
channelId: channelId ?? this.channelId,
channelType: channelType ?? this.channelType,
parentId: parentId ?? this.parentId,
extraData: extraData ?? this.extraData,
);
} }
/// The channel embedded in the event object /// The channel embedded in the event object
@@ -27,4 +27,16 @@ class Read {
/// Serialize to json /// Serialize to json
Map<String, dynamic> toJson() => _$ReadToJson(this); Map<String, dynamic> toJson() => _$ReadToJson(this);
/// Creates a copy of [Read] with specified attributes overridden.
Read copyWith({
DateTime lastRead,
User user,
int unreadMessages,
}) =>
Read(
lastRead: lastRead ?? this.lastRead,
user: user ?? this.user,
unreadMessages: unreadMessages ?? this.unreadMessages,
);
} }
@@ -100,4 +100,28 @@ class User {
/// Serialize to json /// Serialize to json
Map<String, dynamic> toJson() => Map<String, dynamic> toJson() =>
Serialization.moveFromExtraDataToRoot(_$UserToJson(this), topLevelFields); Serialization.moveFromExtraDataToRoot(_$UserToJson(this), topLevelFields);
/// Creates a copy of [User] with specified attributes overridden.
User copyWith({
String id,
String role,
DateTime createdAt,
DateTime updatedAt,
DateTime lastActive,
bool online,
Map<String, dynamic> extraData,
bool banned,
List<String> teams,
}) =>
User(
id: id ?? this.id,
role: role ?? this.role,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
lastActive: lastActive ?? this.lastActive,
online: online ?? this.online,
extraData: extraData ?? this.extraData,
banned: banned ?? this.banned,
teams: teams ?? this.teams,
);
} }
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/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 = '1.5.0'; const PACKAGE_VERSION = '1.5.1';
+2 -2
View File
@@ -1,7 +1,7 @@
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: 1.5.0 version: 1.5.1
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
@@ -20,7 +20,7 @@ dependencies:
mime: ^0.9.7 mime: ^0.9.7
rxdart: ^0.25.0 rxdart: ^0.25.0
uuid: ^2.2.2 uuid: ^2.2.2
web_socket_channel: ^1.1.0 web_socket_channel: ^1.2.0
dev_dependencies: dev_dependencies:
build_runner: ^1.10.0 build_runner: ^1.10.0
@@ -1,9 +0,0 @@
import 'package:test/test.dart';
import 'package:stream_chat/src/api/web_socket_channel_stub.dart';
void main() {
test('src/api/web_socket_stub_test', () {
expect(
() => connectWebSocket('fakeurl'), throwsA(isA<UnimplementedError>()));
});
}
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:test/test.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:stream_chat/src/api/connection_status.dart'; import 'package:stream_chat/src/api/connection_status.dart';
@@ -8,6 +7,7 @@ import 'package:stream_chat/src/api/websocket.dart';
import 'package:stream_chat/src/models/event.dart'; import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:test/test.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
class Functions { class Functions {
@@ -47,10 +47,9 @@ void main() {
final mockWSChannel = MockWSChannel(); final mockWSChannel = MockWSChannel();
final StreamController<String> streamController = final streamController = StreamController<String>.broadcast();
StreamController<String>.broadcast();
final computedUrl = const computedUrl =
'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D';
when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel);
@@ -60,7 +59,7 @@ void main() {
}); });
final timer = Timer.periodic( final timer = Timer.periodic(
Duration(milliseconds: 100), const Duration(milliseconds: 100),
(_) => streamController.sink.add('{}'), (_) => streamController.sink.add('{}'),
); );
@@ -160,6 +159,42 @@ void main() {
return connect; return connect;
}); });
test('should close correctly the controller while connecting', () async {
final handleFunc = MockFunctions().handleFunc;
final ConnectWebSocket connectFunc = MockFunctions().connectFunc;
final ws = WebSocket(
baseUrl: 'baseurl',
user: User(id: 'testid'),
logger: Logger('ws'),
connectParams: {'test': 'true'},
connectPayload: {'payload': 'test'},
handler: handleFunc,
connectFunc: connectFunc,
);
final mockWSChannel = MockWSChannel();
final StreamController<String> streamController =
StreamController<String>.broadcast();
final computedUrl =
'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D';
when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel);
when(mockWSChannel.sink).thenAnswer((_) => MockWSSink());
when(mockWSChannel.stream).thenAnswer((_) {
return streamController.stream;
});
ws.connect();
await ws.disconnect();
streamController.add('{}');
verify(connectFunc(computedUrl)).called(1);
verifyNever(handleFunc(any));
});
test('should run correctly health check', () async { test('should run correctly health check', () async {
final handleFunc = MockFunctions().handleFunc; final handleFunc = MockFunctions().handleFunc;
@@ -1,3 +1,9 @@
## 1.5.2
- Fix accessibility text size overflows
- Updated Giphy attachment ui
- Minor fixes and improvements
## 1.5.1 ## 1.5.1
- Fixed unread count not updating while the chat is open - Fixed unread count not updating while the chat is open
@@ -59,17 +59,38 @@ class GiphyAttachment extends AttachmentWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
Stack(
children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row(
children: [
StreamSvgIcon.giphyIcon(),
SizedBox(width: 8),
Text(
'Giphy',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(width: 8),
if (attachment.title != null)
Flexible(
child: Text(
attachment.title,
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(2.0),
child: GestureDetector( child: GestureDetector(
onTap: () => onAttachmentTap ?? _onImageTap(context), onTap: () => onAttachmentTap ?? _onImageTap(context),
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: CachedNetworkImage( child: CachedNetworkImage(
height: size?.height, height: size?.height,
width: size?.width, width: size?.width,
@@ -90,108 +111,6 @@ class GiphyAttachment extends AttachmentWidget {
), ),
), ),
), ),
),
Positioned(
bottom: 16,
left: 16,
child: Material(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: Row(
children: [
StreamSvgIcon.lightning(
color:
StreamChatTheme.of(context).colorTheme.white,
size: 16,
),
Text(
'GIPHY',
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
),
),
),
],
),
if (attachment.title != null)
Container(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Card(
color: Colors.white,
elevation: 2,
shape: CircleBorder(),
child: IconButton(
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tight(Size(32, 32)),
icon: StreamSvgIcon.left(
size: 24.0,
),
splashRadius: 16,
onPressed: () {
streamChannel.channel.sendAction(message, {
'image_action': 'shuffle',
});
},
),
),
Expanded(
child: Center(
child: Text(
'"${attachment.title}"',
style: TextStyle(
fontStyle: FontStyle.italic,
),
),
),
),
Card(
color: Colors.white,
elevation: 2,
shape: CircleBorder(),
child: IconButton(
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tight(Size(32, 32)),
icon: StreamSvgIcon.right(
size: 24.0,
),
splashRadius: 16,
onPressed: () {
streamChannel.channel.sendAction(message, {
'image_action': 'shuffle',
});
},
),
),
],
),
),
),
SizedBox(
height: 4.0,
),
Container( Container(
color: StreamChatTheme.of(context) color: StreamChatTheme.of(context)
.colorTheme .colorTheme
@@ -236,6 +155,38 @@ class GiphyAttachment extends AttachmentWidget {
.withOpacity(0.2), .withOpacity(0.2),
height: 50.0, height: 50.0,
), ),
Expanded(
child: Container(
height: 50,
child: TextButton(
onPressed: () {
streamChannel.channel.sendAction(message, {
'image_action': 'shuffle',
});
},
child: Text(
'Shuffle',
style: StreamChatTheme.of(context)
.textTheme
.bodyBold
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5),
),
),
),
),
),
Container(
width: 0.5,
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.2),
height: 50.0,
),
Expanded( Expanded(
child: Container( child: Container(
height: 50, height: 50,
@@ -261,9 +212,7 @@ class GiphyAttachment extends AttachmentWidget {
], ],
), ),
), ),
SizedBox( SizedBox(height: 4.0),
height: 4.0,
),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Padding( child: Padding(
@@ -143,7 +143,7 @@ class _ChannelFileDisplayScreenState extends State<ChannelFileDisplayScreen> {
} }
return LazyLoadScrollView( return LazyLoadScrollView(
onEndOfPage: () => messageSearchBloc.loadMore( onEndOfPage: () => messageSearchBloc.search(
filter: { filter: {
'cid': { 'cid': {
r'$in': [StreamChannel.of(context).channel.cid] r'$in': [StreamChannel.of(context).channel.cid]
@@ -628,13 +628,14 @@ class _ChannelListViewState extends State<ChannelListView> {
), ),
); );
} }
return Container( return snapshot.data
height: 100, ? Center(
padding: EdgeInsets.all(32), child: Padding(
child: Center( padding: const EdgeInsets.all(16.0),
child: snapshot.data ? CircularProgressIndicator() : Container(), child: const CircularProgressIndicator(),
), ),
); )
: Offstage();
}); });
} }
@@ -166,7 +166,7 @@ class _ChannelMediaDisplayScreenState extends State<ChannelMediaDisplayScreen> {
} }
return LazyLoadScrollView( return LazyLoadScrollView(
onEndOfPage: () => messageSearchBloc.loadMore( onEndOfPage: () => messageSearchBloc.search(
filter: { filter: {
'cid': { 'cid': {
r'$in': [StreamChannel.of(context).channel.cid] r'$in': [StreamChannel.of(context).channel.cid]
@@ -170,14 +170,14 @@ class ChannelPreview extends StatelessWidget {
if (lastMessageAt.millisecondsSinceEpoch >= if (lastMessageAt.millisecondsSinceEpoch >=
startOfDay.millisecondsSinceEpoch) { startOfDay.millisecondsSinceEpoch) {
stringDate = Jiffy(lastMessageAt.toLocal()).format('HH:mm'); stringDate = Jiffy(lastMessageAt.toLocal()).jm;
} else if (lastMessageAt.millisecondsSinceEpoch >= } else if (lastMessageAt.millisecondsSinceEpoch >=
startOfDay.subtract(Duration(days: 1)).millisecondsSinceEpoch) { startOfDay.subtract(Duration(days: 1)).millisecondsSinceEpoch) {
stringDate = 'Yesterday'; stringDate = 'Yesterday';
} else if (startOfDay.difference(lastMessageAt).inDays < 7) { } else if (startOfDay.difference(lastMessageAt).inDays < 7) {
stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; stringDate = Jiffy(lastMessageAt.toLocal()).EEEE;
} else { } else {
stringDate = Jiffy(lastMessageAt.toLocal()).format('dd/MM/yyyy'); stringDate = Jiffy(lastMessageAt.toLocal()).yMd;
} }
return Text( return Text(
@@ -28,14 +28,14 @@ class DateDivider extends StatelessWidget {
now.subtract(Duration(days: 7)), now.subtract(Duration(days: 7)),
Units.DAY, Units.DAY,
)) { )) {
dayInfo = createdAt.format('EEEE'); dayInfo = createdAt.EEEE;
} else if (Jiffy(createdAt).isAfter( } else if (Jiffy(createdAt).isAfter(
Jiffy(now).subtract(years: 1), Jiffy(now).subtract(years: 1),
Units.DAY, Units.DAY,
)) { )) {
dayInfo = createdAt.format('MMMM d'); dayInfo = createdAt.MMMd;
} else { } else {
dayInfo = createdAt.format('MMMM d'); dayInfo = createdAt.MMMd;
} }
if (uppercase) dayInfo = dayInfo.toUpperCase(); if (uppercase) dayInfo = dayInfo.toUpperCase();
@@ -97,3 +97,8 @@ extension InputDecorationX on InputDecoration {
); );
} }
} }
extension BuildContextX on BuildContext {
double get textScaleFactor =>
MediaQuery.maybeOf(this)?.textScaleFactor ?? 1.0;
}
@@ -185,6 +185,7 @@ class _FullScreenMediaState extends State<FullScreenMedia>
widget.message, StreamChannel.of(context).channel); widget.message, StreamChannel.of(context).channel);
}, },
), ),
if (widget.message.type != 'ephemeral')
ImageFooter( ImageFooter(
currentPage: _currentPage, currentPage: _currentPage,
totalPages: widget.mediaAttachments.length, totalPages: widget.mediaAttachments.length,
@@ -220,7 +221,7 @@ class _FullScreenMediaState extends State<FullScreenMedia>
24) { 24) {
return 'yesterday'; return 'yesterday';
} else { } else {
return 'on ${Jiffy(dateTime).format("MMM do")}'; return 'on ${Jiffy(dateTime).MMMd}';
} }
} }
@@ -78,6 +78,7 @@ class _ImageFooterState extends State<ImageFooter> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showShareButton = !kIsWeb;
return SizedBox.fromSize( return SizedBox.fromSize(
size: Size( size: Size(
MediaQuery.of(context).size.width, MediaQuery.of(context).size.width,
@@ -91,8 +92,10 @@ class _ImageFooterState extends State<ImageFooter> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
kIsWeb !showShareButton
? SizedBox() ? Container(
width: 48,
)
: IconButton( : IconButton(
icon: StreamSvgIcon.iconShare( icon: StreamSvgIcon.iconShare(
size: 24.0, size: 24.0,
@@ -63,6 +63,7 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget {
backgroundColor: backgroundColor:
StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color,
actions: <Widget>[ actions: <Widget>[
if (message.type != 'ephemeral')
IconButton( IconButton(
icon: StreamSvgIcon.iconMenuPoint( icon: StreamSvgIcon.iconMenuPoint(
color: StreamChatTheme.of(context).colorTheme.black, color: StreamChatTheme.of(context).colorTheme.black,
@@ -73,7 +74,8 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget {
), ),
], ],
centerTitle: true, centerTitle: true,
title: InkWell( title: message.type != 'ephemeral'
? InkWell(
onTap: onTitleTap, onTap: onTitleTap,
child: Container( child: Container(
height: preferredSize.height, height: preferredSize.height,
@@ -89,12 +91,15 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget {
), ),
Text( Text(
sentAt, sentAt,
style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, style: StreamChatTheme.of(context)
.channelPreviewTheme
.subtitle,
), ),
], ],
), ),
), ),
), )
: SizedBox(),
); );
} }
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for substituting widgets in respective positions
class MentionTile extends StatelessWidget {
/// Member to display in the tile
final Member member;
/// Widget to display as title
final Widget title;
/// Widget to display below [title]
final Widget subtitle;
/// Widget at the start of the tile
final Widget leading;
/// Widget at the end of tile
final Widget trailing;
MentionTile(
this.member, {
this.title,
this.subtitle,
this.leading,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Container(
height: 56.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 16.0,
),
leading ??
UserAvatar(
constraints: BoxConstraints.tight(
Size(
40,
40,
),
),
user: member.user,
),
SizedBox(
width: 8.0,
),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title ??
Text(
'${member.user.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: StreamChatTheme.of(context).textTheme.bodyBold,
),
SizedBox(
height: 2.0,
),
subtitle ??
Text(
'@${member.userId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: StreamChatTheme.of(context)
.textTheme
.footnoteBold
.copyWith(
color:
StreamChatTheme.of(context).colorTheme.grey,
),
),
],
),
),
),
trailing ??
Padding(
padding: const EdgeInsets.only(right: 18.0, left: 8.0),
child: StreamSvgIcon.mentions(
color: StreamChatTheme.of(context).colorTheme.accentBlue,
),
),
],
),
);
}
}
@@ -16,7 +16,6 @@ import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:substring_highlight/substring_highlight.dart'; import 'package:substring_highlight/substring_highlight.dart';
@@ -32,6 +31,13 @@ typedef AttachmentThumbnailBuilder = Widget Function(
Attachment, Attachment,
); );
/// Builder function for building a mention tile
/// Use [MentionTile] for the default implementation
typedef MentionTileBuilder = Widget Function(
BuildContext context,
Member member,
);
enum ActionsLocation { enum ActionsLocation {
left, left,
right, right,
@@ -125,6 +131,7 @@ class MessageInput extends StatefulWidget {
this.idleSendButton, this.idleSendButton,
this.activeSendButton, this.activeSendButton,
this.showCommandsButton = true, this.showCommandsButton = true,
this.mentionsTileBuilder,
}) : super(key: key); }) : super(key: key);
/// Message to edit /// Message to edit
@@ -191,6 +198,9 @@ class MessageInput extends StatefulWidget {
/// Send button widget in an active state /// Send button widget in an active state
final Widget activeSendButton; final Widget activeSendButton;
/// Customize the tile for the mentions overlay
final MentionTileBuilder mentionsTileBuilder;
@override @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
@@ -319,7 +329,11 @@ class MessageInputState extends State<MessageInput> {
), ),
if (widget.parentMessage != null && !widget.hideSendAsDm) if (widget.parentMessage != null && !widget.hideSendAsDm)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.only(
right: 12.0,
left: 12.0,
bottom: 12.0,
),
child: _buildDmCheckbox(), child: _buildDmCheckbox(),
), ),
_buildFilePickerSection(), _buildFilePickerSection(),
@@ -354,14 +368,7 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildDmCheckbox() { Widget _buildDmCheckbox() {
return Container( return Row(
height: 36,
padding: const EdgeInsets.only(
left: 12,
bottom: 12,
top: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
@@ -411,11 +418,18 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text('Also send as direct message'), child: Text(
'Also send as direct message',
style: StreamChatTheme.of(context).textTheme.footnote.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5),
),
),
), ),
], ],
),
); );
} }
@@ -454,7 +468,9 @@ class MessageInputState extends State<MessageInput> {
? pi ? pi
: 0, : 0,
child: StreamSvgIcon.emptyCircleLeft( child: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context).colorTheme.accentBlue, color: StreamChatTheme.of(context)
.messageInputTheme
.expandButtonColor,
), ),
), ),
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
@@ -491,10 +507,17 @@ class MessageInputState extends State<MessageInput> {
Expanded _buildTextInput(BuildContext context) { Expanded _buildTextInput(BuildContext context) {
final theme = StreamChatTheme.of(context); final theme = StreamChatTheme.of(context);
final margin = (widget.sendButtonLocation == SendButtonLocation.inside
? const EdgeInsets.only(right: 8.0)
: EdgeInsets.zero) +
(widget.actionsLocation != ActionsLocation.left
? const EdgeInsets.only(left: 8.0)
: EdgeInsets.zero);
return Expanded( return Expanded(
child: Center( child: Center(
child: Container( child: Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: margin,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: theme.messageInputTheme.borderRadius, borderRadius: theme.messageInputTheme.borderRadius,
gradient: _focusNode.hasFocus gradient: _focusNode.hasFocus
@@ -656,6 +679,7 @@ class MessageInputState extends State<MessageInput> {
Timer _debounce; Timer _debounce;
String _previousValue; String _previousValue;
void _onChanged(BuildContext context, String s) { void _onChanged(BuildContext context, String s) {
if (s == _previousValue) { if (s == _previousValue) {
return; return;
@@ -1113,6 +1137,7 @@ class MessageInputState extends State<MessageInput> {
_showErrorAlert( _showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.', 'The file is too large to upload. The file size limit is 20MB.',
); );
return;
} }
} }
@@ -1301,80 +1326,9 @@ class MessageInputState extends State<MessageInput> {
_mentionsOverlay?.remove(); _mentionsOverlay?.remove();
_mentionsOverlay = null; _mentionsOverlay = null;
}, },
child: Container( child: widget.mentionsTileBuilder != null
height: 56.0, ? widget.mentionsTileBuilder(context, m)
child: Row( : MentionTile(m),
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
SizedBox(
width: 16.0,
),
UserAvatar(
constraints: BoxConstraints.tight(
Size(
40,
40,
),
),
user: m.user,
),
SizedBox(
width: 8.0,
),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'${m.user.name}',
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: StreamChatTheme.of(
context)
.textTheme
.bodyBold,
),
SizedBox(
height: 2.0,
),
Text(
'@${m.userId}',
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: StreamChatTheme.of(
context)
.textTheme
.footnoteBold
.copyWith(
color: StreamChatTheme
.of(context)
.colorTheme
.grey),
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(
right: 18.0, left: 8.0),
child: StreamSvgIcon.mentions(
color: StreamChatTheme.of(context)
.colorTheme
.accentBlue,
),
),
],
),
),
), ),
); );
}, },
@@ -1962,12 +1916,6 @@ class MessageInputState extends State<MessageInput> {
final extraDataMap = <String, dynamic>{}; final extraDataMap = <String, dynamic>{};
if (mimeType?.type == 'video' || mimeType?.type == 'image') {
attachmentType = mimeType.type;
} else {
attachmentType = 'file';
}
if (mimeType?.subtype != null) { if (mimeType?.subtype != null) {
extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); extraDataMap['mime_type'] = mimeType.subtype.toLowerCase();
} }
@@ -1982,8 +1930,6 @@ class MessageInputState extends State<MessageInput> {
extraData: extraDataMap.isNotEmpty ? extraDataMap : null, extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
); );
_attachments[attachment.id] = attachment;
if (file.size > _kMaxAttachmentSize) { if (file.size > _kMaxAttachmentSize) {
if (attachmentType == 'Video') { if (attachmentType == 'Video') {
final mediaInfo = await VideoService.compressVideo(file.path); final mediaInfo = await VideoService.compressVideo(file.path);
@@ -1992,7 +1938,6 @@ class MessageInputState extends State<MessageInput> {
_showErrorAlert( _showErrorAlert(
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
); );
_attachments.remove(attachment.id);
return; return;
} }
file = AttachmentFile( file = AttachmentFile(
@@ -2005,9 +1950,12 @@ class MessageInputState extends State<MessageInput> {
_showErrorAlert( _showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.', 'The file is too large to upload. The file size limit is 20MB.',
); );
return;
} }
} }
_attachments[attachment.id] = attachment;
setState(() { setState(() {
_attachments.update(attachment.id, (it) { _attachments.update(attachment.id, (it) {
return it.copyWith( return it.copyWith(
@@ -2233,7 +2181,7 @@ class MessageInputState extends State<MessageInput> {
void _parseExistingMessage(Message message) { void _parseExistingMessage(Message message) {
textEditingController.text = message.text; textEditingController.text = message.text;
_messageIsPresent = true; _messageIsPresent = true;
for (final attachment in message.attachments) { for (final attachment in message?.attachments) {
_attachments[attachment.id] = attachment.copyWith( _attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState ?? UploadState.success(), uploadState: attachment.uploadState ?? UploadState.success(),
); );
@@ -141,6 +141,7 @@ class MessageListView extends StatefulWidget {
this.onMessageTap, this.onMessageTap,
this.onSystemMessageTap, this.onSystemMessageTap,
this.onAttachmentTap, this.onAttachmentTap,
this.textBuilder,
}) : super(key: key); }) : super(key: key);
/// Function used to build a custom message widget /// Function used to build a custom message widget
@@ -230,9 +231,12 @@ class MessageListView extends StatefulWidget {
/// Called when system message is tapped /// Called when system message is tapped
final OnMessageTap onSystemMessageTap; final OnMessageTap onSystemMessageTap;
// Customize onTap on attachment /// Customize onTap on attachment
final void Function(Message message, Attachment attachment) onAttachmentTap; final void Function(Message message, Attachment attachment) onAttachmentTap;
/// Customize the MessageWidget textBuilder
final void Function(BuildContext context, Message message) textBuilder;
@override @override
_MessageListViewState createState() => _MessageListViewState(); _MessageListViewState createState() => _MessageListViewState();
} }
@@ -839,6 +843,7 @@ class _MessageListViewState extends State<MessageListView> {
} }
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
textBuilder: widget.textBuilder,
); );
} }
@@ -1015,6 +1020,7 @@ class _MessageListViewState extends State<MessageListView> {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
}, },
onAttachmentTap: widget.onAttachmentTap, onAttachmentTap: widget.onAttachmentTap,
textBuilder: widget.textBuilder,
); );
if (!message.isDeleted && if (!message.isDeleted &&
@@ -84,9 +84,9 @@ class MessageSearchItem extends StatelessWidget {
if (now.year != createdAt.year || if (now.year != createdAt.year ||
now.month != createdAt.month || now.month != createdAt.month ||
now.day != createdAt.day) { now.day != createdAt.day) {
stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy'); stringDate = Jiffy(createdAt.toLocal()).yMd;
} else { } else {
stringDate = Jiffy(createdAt.toLocal()).format('HH:mm'); stringDate = Jiffy(createdAt.toLocal()).jm;
} }
return Text( return Text(
@@ -262,17 +262,13 @@ class _MessageSearchListViewState extends State<MessageSearchListView> {
); );
if (widget.pullToRefresh) { if (widget.pullToRefresh) {
child = RefreshIndicator( child = RefreshIndicator(
onRefresh: () async { onRefresh: () => _messageSearchListController.loadData(),
_messageSearchListController.loadData();
},
child: child, child: child,
); );
} }
child = LazyLoadScrollView( child = LazyLoadScrollView(
onEndOfPage: () async { onEndOfPage: () => _messageSearchListController.paginateData(),
return _messageSearchListController.paginateData();
},
child: child, child: child,
); );
@@ -585,7 +585,8 @@ class _MessageWidgetState extends State<MessageWidget>
), ),
], ],
), ),
if (showBottomRow) SizedBox(height: 20.0), if (showBottomRow)
SizedBox(height: context.textScaleFactor * 18.0),
], ],
), ),
if (showBottomRow) if (showBottomRow)
@@ -682,6 +683,8 @@ class _MessageWidgetState extends State<MessageWidget>
} }
}; };
const usernameKey = Key('username');
children.addAll([ children.addAll([
if (showInChannel || showThreadReplyIndicator) ...[ if (showInChannel || showThreadReplyIndicator) ...[
if (showThreadParticipants) if (showThreadParticipants)
@@ -697,7 +700,10 @@ class _MessageWidgetState extends State<MessageWidget>
if (showUsername) if (showUsername)
Text( Text(
widget.message.user.name, widget.message.user.name,
maxLines: 1,
key: usernameKey,
style: widget.messageTheme.messageAuthor, style: widget.messageTheme.messageAuthor,
overflow: TextOverflow.ellipsis,
), ),
if (showTimeStamp) if (showTimeStamp)
Text( Text(
@@ -710,18 +716,17 @@ class _MessageWidgetState extends State<MessageWidget>
final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) &&
(showThreadReplyIndicator || showInChannel); (showThreadReplyIndicator || showInChannel);
return Flex( return Row(
direction: Axis.horizontal,
clipBehavior: Clip.none,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (showThreadTail) if (showThreadTail)
Container( Container(
margin: EdgeInsets.only( margin: EdgeInsets.only(
bottom: widget.messageTheme.replies.fontSize / 2, bottom: context.textScaleFactor *
(widget.messageTheme.replies.fontSize / 2),
), ),
child: CustomPaint( child: CustomPaint(
size: const Size(16, 32), size: Size(16, 32) * context.textScaleFactor,
painter: _ThreadReplyPainter( painter: _ThreadReplyPainter(
context: context, context: context,
color: widget.messageTheme.messageBorderColor, color: widget.messageTheme.messageBorderColor,
@@ -729,16 +734,20 @@ class _MessageWidgetState extends State<MessageWidget>
), ),
), ),
...children.map( ...children.map(
(child) => Transform( (child) {
Widget mappedChild = Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0), transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center, alignment: Alignment.center,
child: Container( child: Container(
height: 16, height: context.textScaleFactor * 14,
child: Center(
child: child, child: child,
), ),
), );
), if (child.key == usernameKey) {
mappedChild = Flexible(child: mappedChild);
}
return mappedChild;
},
), ),
].insertBetween(const SizedBox(width: 8.0)), ].insertBetween(const SizedBox(width: 8.0)),
); );
@@ -3,9 +3,11 @@ import 'dart:async';
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:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'dart:ui' as ui;
/// Widget used to provide information about the chat to the widget tree /// Widget used to provide information about the chat to the widget tree
/// ///
@@ -121,4 +123,11 @@ class StreamChatState extends State<StreamChat> {
void initState() { void initState() {
super.initState(); super.initState();
} }
@override
void didChangeDependencies() {
final locale = ui.window.locale;
Jiffy.locale(locale.languageCode);
super.didChangeDependencies();
}
} }
@@ -272,6 +272,7 @@ class StreamChatThemeData {
sendAnimationDuration: Duration(milliseconds: 300), sendAnimationDuration: Duration(milliseconds: 300),
actionButtonColor: colorTheme.accentBlue, actionButtonColor: colorTheme.accentBlue,
actionButtonIdleColor: colorTheme.grey, actionButtonIdleColor: colorTheme.grey,
expandButtonColor: colorTheme.accentBlue,
sendButtonColor: colorTheme.accentBlue, sendButtonColor: colorTheme.accentBlue,
sendButtonIdleColor: colorTheme.greyGainsboro, sendButtonIdleColor: colorTheme.greyGainsboro,
inputBackground: colorTheme.white, inputBackground: colorTheme.white,
@@ -935,6 +936,9 @@ class MessageInputTheme {
/// Background color of [MessageInput] action buttons /// Background color of [MessageInput] action buttons
final Color actionButtonIdleColor; final Color actionButtonIdleColor;
/// Background color of [MessageInput] expand button
final Color expandButtonColor;
/// Background color of [MessageInput] /// Background color of [MessageInput]
final Color inputBackground; final Color inputBackground;
@@ -966,6 +970,7 @@ class MessageInputTheme {
this.activeBorderGradient, this.activeBorderGradient,
this.idleBorderGradient, this.idleBorderGradient,
this.borderRadius, this.borderRadius,
this.expandButtonColor,
}); });
/// Returns a new [MessageInputTheme] replacing some of its properties /// Returns a new [MessageInputTheme] replacing some of its properties
@@ -976,6 +981,7 @@ class MessageInputTheme {
Color sendButtonColor, Color sendButtonColor,
Color actionButtonIdleColor, Color actionButtonIdleColor,
Color sendButtonIdleColor, Color sendButtonIdleColor,
Color expandButtonColor,
TextStyle inputTextStyle, TextStyle inputTextStyle,
InputDecoration inputDecoration, InputDecoration inputDecoration,
Gradient activeBorderGradient, Gradient activeBorderGradient,
@@ -990,6 +996,7 @@ class MessageInputTheme {
sendButtonColor: sendButtonColor ?? this.sendButtonColor, sendButtonColor: sendButtonColor ?? this.sendButtonColor,
actionButtonIdleColor: actionButtonIdleColor:
actionButtonIdleColor ?? this.actionButtonIdleColor, actionButtonIdleColor ?? this.actionButtonIdleColor,
expandButtonColor: expandButtonColor ?? this.expandButtonColor,
inputTextStyle: inputTextStyle ?? this.inputTextStyle, inputTextStyle: inputTextStyle ?? this.inputTextStyle,
sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor,
inputDecoration: inputDecoration ?? this.inputDecoration, inputDecoration: inputDecoration ?? this.inputDecoration,
@@ -1014,6 +1021,7 @@ class MessageInputTheme {
activeBorderGradient: other.activeBorderGradient, activeBorderGradient: other.activeBorderGradient,
idleBorderGradient: other.idleBorderGradient, idleBorderGradient: other.idleBorderGradient,
borderRadius: other.borderRadius, borderRadius: other.borderRadius,
expandButtonColor: other.expandButtonColor,
); );
} }
} }
@@ -193,9 +193,7 @@ class _UserListViewState extends State<UserListView>
return child; return child;
} else { } else {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () => _userListController.loadData(),
_userListController.loadData();
},
child: child, child: child,
); );
} }
@@ -243,9 +241,7 @@ class _UserListViewState extends State<UserListView>
child: Text(message), child: Text(message),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () => _userListController.loadData(),
_userListController.loadData();
},
child: Text('Retry'), child: Text('Retry'),
), ),
], ],
@@ -300,9 +296,7 @@ class _UserListViewState extends State<UserListView>
); );
return LazyLoadScrollView( return LazyLoadScrollView(
onEndOfPage: () async { onEndOfPage: () => _userListController.paginateData(),
return _userListController.paginateData();
},
child: child, child: child,
); );
} }
@@ -39,4 +39,5 @@ export 'src/channel_media_display_screen.dart';
export 'src/info_tile.dart'; export 'src/info_tile.dart';
export 'src/stream_chat.dart'; export 'src/stream_chat.dart';
export 'src/connection_status_builder.dart'; export 'src/connection_status_builder.dart';
export 'src/mention_tile.dart';
export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
+2 -5
View File
@@ -1,7 +1,7 @@
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: 1.5.1 version: 1.5.2
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
@@ -11,7 +11,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
stream_chat_flutter_core: ^1.5.0 stream_chat_flutter_core: ^1.5.1
photo_view: ^0.11.0 photo_view: ^0.11.0
rxdart: ^0.25.0 rxdart: ^0.25.0
scrollable_positioned_list: ^0.1.8 scrollable_positioned_list: ^0.1.8
@@ -30,16 +30,13 @@ dependencies:
flutter_keyboard_visibility: ^4.0.2 flutter_keyboard_visibility: ^4.0.2
video_compress: ^2.1.1 video_compress: ^2.1.1
visibility_detector: ^0.1.5 visibility_detector: ^0.1.5
http_parser: ^3.1.4
meta: ^1.2.4 meta: ^1.2.4
lottie: ^0.7.0+1 lottie: ^0.7.0+1
substring_highlight: ^0.1.2 substring_highlight: ^0.1.2
flutter_slidable: ^0.5.7 flutter_slidable: ^0.5.7
clipboard: ^0.1.2+8
image_gallery_saver: ^1.6.7 image_gallery_saver: ^1.6.7
share_plus: ^1.2.0 share_plus: ^1.2.0
photo_manager: ^1.0.0 photo_manager: ^1.0.0
transparent_image: ^1.0.0
ezanimation: ^0.4.1 ezanimation: ^0.4.1
synchronized: ^2.1.0 synchronized: ^2.1.0
characters: ^1.0.0 characters: ^1.0.0
@@ -69,7 +69,7 @@ void main() {
), ),
)); ));
expect(find.text('22/06/2020'), findsOneWidget); expect(find.text('6/22/2020'), findsOneWidget);
expect(find.text('test name'), findsOneWidget); expect(find.text('test name'), findsOneWidget);
expect(find.text('1'), findsOneWidget); expect(find.text('1'), findsOneWidget);
expect(find.text('hello'), findsOneWidget); expect(find.text('hello'), findsOneWidget);
@@ -1,3 +1,8 @@
## 1.5.1
* Improved test coverage to > 90%
* Minor fixes and improvements
## 1.5.0 ## 1.5.0
* Minor fixes and improvements * Minor fixes and improvements
@@ -0,0 +1,145 @@
analyzer:
exclude:
- lib/**/*.g.dart
- lib/**/*.freezed.dart
- example/*
- test/*
linter:
rules:
- always_use_package_imports
- avoid_empty_else
- avoid_relative_lib_imports
- avoid_slow_async_io
- avoid_types_as_parameter_names
- cancel_subscriptions
- close_sinks
- control_flow_in_finally
- empty_statements
- hash_and_equals
- invariant_booleans
- iterable_contains_unrelated_type
- list_remove_unrelated_type
- literal_only_boolean_expressions
- no_adjacent_strings_in_list
- no_duplicate_case_values
- no_logic_in_create_state
- prefer_void_to_null
- test_types_in_equals
- throw_in_finally
- unnecessary_statements
- unrelated_type_equality_checks
- omit_local_variable_types
- use_key_in_widget_constructors
- valid_regexps
- always_declare_return_types
- always_require_non_null_named_parameters
- annotate_overrides
- avoid_bool_literals_in_conditional_expressions
- avoid_catching_errors
- avoid_init_to_null
- avoid_null_checks_in_equality_operators
- avoid_positional_boolean_parameters
- avoid_private_typedef_functions
- avoid_redundant_argument_values
- avoid_return_types_on_setters
- avoid_returning_null
- avoid_returning_null_for_void
- avoid_shadowing_type_parameters
- avoid_single_cascade_in_expression_statements
- avoid_unnecessary_containers
- avoid_unused_constructor_parameters
- await_only_futures
- camel_case_extensions
- camel_case_types
- cascade_invocations
- constant_identifier_names
- curly_braces_in_flow_control_structures
- directives_ordering
- empty_catches
- empty_constructor_bodies
- exhaustive_cases
- file_names
- implementation_imports
- join_return_with_assignment
- leading_newlines_in_multiline_strings
- library_names
- library_prefixes
- lines_longer_than_80_chars
- missing_whitespace_between_adjacent_strings
- non_constant_identifier_names
- null_closures
- one_member_abstracts
- only_throw_errors
- package_api_docs
- package_prefixed_library_names
- parameter_assignments
- prefer_adjacent_string_concatenation
- prefer_asserts_in_initializer_lists
- prefer_asserts_with_message
- prefer_collection_literals
- prefer_conditional_assignment
- prefer_const_constructors
- prefer_const_constructors_in_immutables
- prefer_const_declarations
- prefer_const_literals_to_create_immutables
- prefer_constructors_over_static_methods
- prefer_contains
- prefer_equal_for_default_values
- prefer_expression_function_bodies
- prefer_final_fields
- prefer_final_in_for_each
- prefer_final_locals
- prefer_function_declarations_over_variables
- prefer_generic_function_type_aliases
- prefer_if_elements_to_conditional_expressions
- prefer_if_null_operators
- prefer_initializing_formals
- prefer_inlined_adds
- prefer_int_literals
- prefer_interpolation_to_compose_strings
- prefer_is_empty
- prefer_is_not_empty
- prefer_is_not_operator
- prefer_null_aware_operators
- prefer_single_quotes
- prefer_spread_collections
- prefer_typing_uninitialized_variables
- provide_deprecation_message
- public_member_api_docs
- recursive_getters
- sized_box_for_whitespace
- slash_for_doc_comments
- sort_child_properties_last
- sort_constructors_first
- sort_unnamed_constructors_first
- type_annotate_public_apis
- type_init_formals
- unnecessary_await_in_return
- unnecessary_brace_in_string_interps
- unnecessary_const
- unnecessary_getters_setters
- unnecessary_lambdas
- unnecessary_new
- unnecessary_null_aware_assignments
- unnecessary_null_in_if_null_operators
- unnecessary_nullable_for_final_variable_declarations
- unnecessary_parenthesis
- unnecessary_raw_strings
- unnecessary_string_escapes
- unnecessary_string_interpolations
- unnecessary_this
- use_is_even_rather_than_modulo
- use_late_for_private_fields_and_variables
- use_rethrow_when_possible
- use_setters_to_change_properties
- use_to_and_as_if_applicable
- package_names
- sort_pub_dependencies
# To be added when null-safe:
# - cast_nullable_to_non_nullable
#- unnecessary_null_checks
# - tighten_type_of_initializing_formals
# - null_check_on_nullable_type_parameter
@@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/channels_bloc.dart'; import 'package:stream_chat_flutter_core/src/channels_bloc.dart';
import 'package:stream_chat_flutter_core/src/stream_chat_core.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart';
import 'stream_chat_core.dart'; /// [ChannelListCore] is a simplified class that allows fetching a list of
/// channels while exposing UI builders.
/// [ChannelListCore] is a simplified class that allows fetching a list of channels while exposing UI builders.
/// A [ChannelListController] is used to reload and paginate data. /// A [ChannelListController] is used to reload and paginate data.
/// ///
/// ///
@@ -52,10 +52,11 @@ import 'stream_chat_core.dart';
/// } /// }
/// ``` /// ```
/// ///
/// Make sure to have a [StreamChatCore] ancestor in order to provide the information about the channels. /// Make sure to have a [StreamChatCore] ancestor in order to provide the
/// information about the channels.
class ChannelListCore extends StatefulWidget { class ChannelListCore extends StatefulWidget {
/// Instantiate a new ChannelListView /// Instantiate a new ChannelListView
ChannelListCore({ const ChannelListCore({
Key key, Key key,
@required this.errorBuilder, @required this.errorBuilder,
@required this.emptyBuilder, @required this.emptyBuilder,
@@ -65,18 +66,31 @@ class ChannelListCore extends StatefulWidget {
this.options, this.options,
this.sort, this.sort,
this.pagination = const PaginationParams( this.pagination = const PaginationParams(
offset: 0,
limit: 25, limit: 25,
), ),
this.channelListController, this.channelListController,
}) : assert(errorBuilder != null), }) : assert(
assert(emptyBuilder != null), errorBuilder != null,
assert(loadingBuilder != null), 'Parameter errorBuilder should not be null',
assert(listBuilder != null), ),
assert(
emptyBuilder != null,
'Parameter emptyBuilder should not be null',
),
assert(
loadingBuilder != null,
'Parameter loadingBuilder should not be null',
),
assert(
listBuilder != null,
'Parameter listBuilder should not be null',
),
super(key: key); super(key: key);
/// A [ChannelListController] allows reloading and pagination. /// A [ChannelListController] allows reloading and pagination.
/// Use [ChannelListController.loadData] and [ChannelListController.paginateData] respectively for reloading and pagination. /// Use [ChannelListController.loadData] and
/// [ChannelListController.paginateData] respectively for reloading and
/// pagination.
final ChannelListController channelListController; final ChannelListController channelListController;
/// The builder that will be used in case of error /// The builder that will be used in case of error
@@ -103,9 +117,10 @@ class ChannelListCore extends StatefulWidget {
final Map<String, dynamic> options; final Map<String, dynamic> options;
/// The sorting used for the channels matching the filters. /// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can be provided. /// Sorting is based on field and direction, multiple sorting options can be
/// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. /// provided.
/// Direction can be ascending or descending. /// You can sort based on last_updated, last_message_at, updated_at, created
/// _at or member_count. Direction can be ascending or descending.
final List<SortOption<ChannelModel>> sort; final List<SortOption<ChannelModel>> sort;
/// Pagination parameters /// Pagination parameters
@@ -115,10 +130,11 @@ class ChannelListCore extends StatefulWidget {
final PaginationParams pagination; final PaginationParams pagination;
@override @override
_ChannelListCoreState createState() => _ChannelListCoreState(); ChannelListCoreState createState() => ChannelListCoreState();
} }
class _ChannelListCoreState extends State<ChannelListCore> { /// The current state of the [ChannelListCore].
class ChannelListCoreState extends State<ChannelListCore> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelsBloc = ChannelsBloc.of(context); final channelsBloc = ChannelsBloc.of(context);
@@ -128,12 +144,12 @@ class _ChannelListCoreState extends State<ChannelListCore> {
StreamBuilder<List<Channel>> _buildListView( StreamBuilder<List<Channel>> _buildListView(
ChannelsBlocState channelsBlocState, ChannelsBlocState channelsBlocState,
) { ) =>
return StreamBuilder<List<Channel>>( StreamBuilder<List<Channel>>(
stream: channelsBlocState.channelsStream, stream: channelsBlocState.channelsStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
return _buildErrorWidget(snapshot, context, channelsBlocState); return widget.errorBuilder(context, snapshot.error);
} }
if (!snapshot.hasData) { if (!snapshot.hasData) {
return widget.loadingBuilder(context); return widget.loadingBuilder(context);
@@ -145,20 +161,8 @@ class _ChannelListCoreState extends State<ChannelListCore> {
return widget.listBuilder(context, channels); return widget.listBuilder(context, channels);
}, },
); );
}
Widget _buildErrorWidget(
AsyncSnapshot<List<Channel>> snapshot,
BuildContext context,
ChannelsBlocState channelsBlocState,
) {
if (snapshot.error is Error) {
print((snapshot.error as Error).stackTrace);
}
return widget.errorBuilder(context, snapshot.error);
}
/// Fetches initial channels and updates the widget
Future<void> loadData() { Future<void> loadData() {
final channelsBloc = ChannelsBloc.of(context); final channelsBloc = ChannelsBloc.of(context);
return channelsBloc.queryChannels( return channelsBloc.queryChannels(
@@ -169,6 +173,7 @@ class _ChannelListCoreState extends State<ChannelListCore> {
); );
} }
/// Fetches more channels with updated pagination and updates the widget
Future<void> paginateData() { Future<void> paginateData() {
final channelsBloc = ChannelsBloc.of(context); final channelsBloc = ChannelsBloc.of(context);
return channelsBloc.queryChannels( return channelsBloc.queryChannels(
@@ -209,9 +214,9 @@ class _ChannelListCoreState extends State<ChannelListCore> {
if (widget.filter?.toString() != oldWidget.filter?.toString() || if (widget.filter?.toString() != oldWidget.filter?.toString() ||
jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) ||
widget.options?.toString() != oldWidget.options?.toString() ||
widget.pagination?.toJson()?.toString() != widget.pagination?.toJson()?.toString() !=
oldWidget.pagination?.toJson()?.toString() || oldWidget.pagination?.toJson()?.toString()) {
widget.options?.toString() != oldWidget.options?.toString()) {
loadData(); loadData();
} }
} }
@@ -223,14 +228,15 @@ class _ChannelListCoreState extends State<ChannelListCore> {
} }
} }
/// Controller used for loading more data and controlling pagination in [ChannelListCore]. /// Controller used for loading more data and controlling pagination in
/// [ChannelListCore].
class ChannelListController { class ChannelListController {
/// This function calls Stream's servers to load a list of channels. If there is existing data, /// This function calls Stream's servers to load a list of channels.
/// calling this function causes a reload. /// If there is existing data, calling this function causes a reload.
AsyncCallback loadData; AsyncCallback loadData;
/// This function is used to load another page of data. Note, [loadData] should be /// This function is used to load another page of data. Note, [loadData]
/// used to populate the initial page of data. Calling [paginateData] performs a query /// should be used to populate the initial page of data. Calling
/// to load subsequent pages. /// [paginateData] performs a query to load subsequent pages.
AsyncCallback paginateData; AsyncCallback paginateData;
} }
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
@@ -16,26 +17,29 @@ import 'package:stream_chat_flutter_core/src/stream_chat_core.dart';
/// ///
/// API docs: https://getstream.io/chat/docs/flutter-dart/query_channels/ /// API docs: https://getstream.io/chat/docs/flutter-dart/query_channels/
class ChannelsBloc extends StatefulWidget { class ChannelsBloc extends StatefulWidget {
/// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and not null. /// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and
/// not null.
const ChannelsBloc({ const ChannelsBloc({
Key key, Key key,
@required this.child, @required this.child,
this.lockChannelsOrder = false, this.lockChannelsOrder = false,
this.channelsComparator, this.channelsComparator,
this.shouldAddChannel, this.shouldAddChannel,
}) : assert(child != null), }) : assert(child != null, 'Parameter child should not be null.'),
super(key: key); super(key: key);
/// The widget child /// The widget child
final Widget child; final Widget child;
/// Set this to true to prevent channels to be brought to the top of the list when a new message arrives /// Set this to true to prevent channels to be brought to the top of the list
/// when a new message arrives
final bool lockChannelsOrder; final bool lockChannelsOrder;
/// Comparator used to sort the channels when a message.new event is received /// Comparator used to sort the channels when a message.new event is received
final Comparator<Channel> channelsComparator; final Comparator<Channel> channelsComparator;
/// Function used to evaluate if a channel should be added to the list when a message.new event is received /// Function used to evaluate if a channel should be added to the list when a
/// message.new event is received
final bool Function(Event) shouldAddChannel; final bool Function(Event) shouldAddChannel;
@override @override
@@ -70,10 +74,9 @@ class ChannelsBlocState extends State<ChannelsBloc>
/// The current channel list as a stream /// The current channel list as a stream
Stream<List<Channel>> get channelsStream => _channelsController.stream; Stream<List<Channel>> get channelsStream => _channelsController.stream;
final BehaviorSubject<bool> _queryChannelsLoadingController = final _queryChannelsLoadingController = BehaviorSubject.seeded(false);
BehaviorSubject.seeded(false);
final BehaviorSubject<List<Channel>> _channelsController = BehaviorSubject(); final _channelsController = BehaviorSubject<List<Channel>>();
/// The stream notifying the state of queryChannel call /// The stream notifying the state of queryChannel call
Stream<bool> get queryChannelsLoading => Stream<bool> get queryChannelsLoading =>
@@ -81,45 +84,58 @@ class ChannelsBlocState extends State<ChannelsBloc>
final List<Channel> _hiddenChannels = []; final List<Channel> _hiddenChannels = [];
bool _paginationEnded = false;
/// Calls [client.queryChannels] updating [queryChannelsLoading] stream /// Calls [client.queryChannels] updating [queryChannelsLoading] stream
Future<void> queryChannels({ Future<void> queryChannels({
Map<String, dynamic> filter, Map<String, dynamic> filter,
List<SortOption<ChannelModel>> sortOptions, List<SortOption<ChannelModel>> sortOptions,
PaginationParams paginationParams, PaginationParams paginationParams,
Map<String, dynamic> options, Map<String, dynamic> options,
bool onlyOffline = false,
}) async { }) async {
final client = StreamChatCore.of(context).client; final client = StreamChatCore.of(context).client;
if (client.state?.user == null || if (_paginationEnded || _queryChannelsLoadingController.value == true) {
_queryChannelsLoadingController.value == true) {
return; return;
} }
_queryChannelsLoadingController.sink.add(true);
if (_channelsController.hasValue) {
_queryChannelsLoadingController.add(true);
}
try { try {
final clear = paginationParams == null || final clear = paginationParams == null ||
paginationParams.offset == null || paginationParams.offset == null ||
paginationParams.offset == 0; paginationParams.offset == 0;
final oldChannels = List<Channel>.from(channels ?? []); final oldChannels = List<Channel>.from(channels ?? []);
var newChannels = <Channel>[];
await for (final channels in client.queryChannels( await for (final channels in client.queryChannels(
filter: filter, filter: filter,
sort: sortOptions, sort: sortOptions,
options: options, options: options,
paginationParams: paginationParams, paginationParams: paginationParams,
)) { )) {
newChannels = channels;
if (clear) { if (clear) {
_channelsController.add(channels); _channelsController.add(channels);
} else { } else {
final l = oldChannels + channels; final temp = oldChannels + channels;
_channelsController.add(l); _channelsController.add(temp);
} }
if (_channelsController.hasValue &&
_queryChannelsLoadingController.value) {
_queryChannelsLoadingController.sink.add(false); _queryChannelsLoadingController.sink.add(false);
} }
} catch (err, stackTrace) { }
print(err); if (newChannels.isEmpty || newChannels.length < paginationParams.limit) {
print(stackTrace); _paginationEnded = true;
_queryChannelsLoadingController.addError(err, stackTrace); }
} catch (e, stk) {
if (_channelsController.hasValue) {
_queryChannelsLoadingController.addError(e, stk);
} else {
_channelsController.addError(e, stk);
}
} }
} }
@@ -135,15 +151,14 @@ class ChannelsBlocState extends State<ChannelsBloc>
_subscriptions.add(client.on(EventType.messageNew).listen((e) { _subscriptions.add(client.on(EventType.messageNew).listen((e) {
final newChannels = List<Channel>.from(channels ?? []); final newChannels = List<Channel>.from(channels ?? []);
final index = newChannels.indexWhere((c) => c.cid == e.cid); final index = newChannels.indexWhere((c) => c.cid == e.cid);
if (index > -1) { if (index != -1) {
if (index > 0) { if (index > 0) {
final channel = newChannels.removeAt(index); final channel = newChannels.removeAt(index);
newChannels.insert(0, channel); newChannels.insert(0, channel);
} }
} else if (widget.shouldAddChannel != null && } else if (widget.shouldAddChannel?.call(e) == true) {
widget.shouldAddChannel(e)) {
final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid); final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid);
if (hiddenIndex > -1) { if (hiddenIndex != -1) {
newChannels.insert(0, _hiddenChannels[hiddenIndex]); newChannels.insert(0, _hiddenChannels[hiddenIndex]);
_hiddenChannels.removeAt(hiddenIndex); _hiddenChannels.removeAt(hiddenIndex);
} else { } else {
@@ -170,13 +185,14 @@ class ChannelsBlocState extends State<ChannelsBloc>
_channelsController.add(newChannels); _channelsController.add(newChannels);
} }
})); }));
// ignore: cascade_invocations
_subscriptions.add(client _subscriptions.add(client
.on( .on(
EventType.channelDeleted, EventType.channelDeleted,
EventType.notificationRemovedFromChannel, EventType.notificationRemovedFromChannel,
) )
.listen((e) { .listen((e) {
// ignore: cascade_invocations
final channel = e.channel; final channel = e.channel;
_channelsController.add(List.from( _channelsController.add(List.from(
(channels ?? [])..removeWhere((c) => c.cid == channel.cid))); (channels ?? [])..removeWhere((c) => c.cid == channel.cid)));
@@ -1,13 +1,13 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum _LoadingStatus { LOADING, STABLE } enum _LoadingStatus { loading, stable }
/// Wrapper around a [Scrollable] which triggers [onEndOfPage]/[onStartOfPage] the Scrollable /// Wrapper around a [Scrollable] which triggers [onEndOfPage]/[onStartOfPage] the Scrollable
/// reaches to the start or end of the view extent. /// reaches to the start or end of the view extent.
class LazyLoadScrollView extends StatefulWidget { class LazyLoadScrollView extends StatefulWidget {
/// Creates a new instance of [LazyLoadScrollView]. The parameter [child] must be /// Creates a new instance of [LazyLoadScrollView]. The parameter [child]
/// supplied and not null. /// must be supplied and not null.
const LazyLoadScrollView({ const LazyLoadScrollView({
Key key, Key key,
@required this.child, @required this.child,
@@ -17,7 +17,7 @@ class LazyLoadScrollView extends StatefulWidget {
this.onPageScrollEnd, this.onPageScrollEnd,
this.onInBetweenOfPage, this.onInBetweenOfPage,
this.scrollOffset = 100, this.scrollOffset = 100,
}) : assert(child != null), }) : assert(child != null, 'Parameter child should not be null'),
super(key: key); super(key: key);
/// The [Widget] that this widget watches for changes on /// The [Widget] that this widget watches for changes on
@@ -46,18 +46,17 @@ class LazyLoadScrollView extends StatefulWidget {
} }
class _LazyLoadScrollViewState extends State<LazyLoadScrollView> { class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
_LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE; _LoadingStatus _loadMoreStatus = _LoadingStatus.stable;
double _scrollPosition = 0.0; double _scrollPosition = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) =>
return NotificationListener( NotificationListener<ScrollNotification>(
child: widget.child,
onNotification: _onNotification, onNotification: _onNotification,
child: widget.child,
); );
}
bool _onNotification(Notification notification) { bool _onNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification) { if (notification is ScrollStartNotification) {
if (widget.onPageScrollStart != null) { if (widget.onPageScrollStart != null) {
widget.onPageScrollStart(); widget.onPageScrollStart();
@@ -74,7 +73,7 @@ class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
final pixels = notification.metrics.pixels; final pixels = notification.metrics.pixels;
final maxScrollExtent = notification.metrics.maxScrollExtent; final maxScrollExtent = notification.metrics.maxScrollExtent;
final minScrollExtent = notification.metrics.minScrollExtent; final minScrollExtent = notification.metrics.minScrollExtent;
final scrollOffset = widget.scrollOffset; final scrollOffset = widget.scrollOffset ?? 0;
if (pixels > (minScrollExtent + scrollOffset) && if (pixels > (minScrollExtent + scrollOffset) &&
pixels < (maxScrollExtent - scrollOffset)) { pixels < (maxScrollExtent - scrollOffset)) {
@@ -88,27 +87,20 @@ class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
final extentAfter = notification.metrics.extentAfter; final extentAfter = notification.metrics.extentAfter;
final scrollingDown = _scrollPosition < pixels; final scrollingDown = _scrollPosition < pixels;
if (scrollOffset == null || scrollOffset == 0) {
if (extentAfter == 0) {
_onEndOfPage();
}
if (extentBefore == 0) {
_onStartOfPage();
}
} else {
if (scrollingDown) { if (scrollingDown) {
if (extentAfter <= scrollOffset) { if (extentAfter <= scrollOffset) {
_onEndOfPage(); _onEndOfPage();
return true;
} }
} else { } else {
if (extentBefore <= scrollOffset) { if (extentBefore <= scrollOffset) {
_onStartOfPage(); _onStartOfPage();
}
}
}
_scrollPosition = pixels;
return true; return true;
} }
}
_scrollPosition = pixels;
}
if (notification is OverscrollNotification) { if (notification is OverscrollNotification) {
if (notification.overscroll > 0) { if (notification.overscroll > 0) {
_onEndOfPage(); _onEndOfPage();
@@ -122,22 +114,22 @@ class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
} }
void _onEndOfPage() { void _onEndOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onEndOfPage != null) { if (widget.onEndOfPage != null) {
_loadMoreStatus = _LoadingStatus.loading;
widget.onEndOfPage().whenComplete(() { widget.onEndOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE; _loadMoreStatus = _LoadingStatus.stable;
}); });
} }
} }
} }
void _onStartOfPage() { void _onStartOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onStartOfPage != null) { if (widget.onStartOfPage != null) {
_loadMoreStatus = _LoadingStatus.loading;
widget.onStartOfPage().whenComplete(() { widget.onStartOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE; _loadMoreStatus = _LoadingStatus.stable;
}); });
} }
} }
@@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/stream_channel.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart';
import 'stream_channel.dart';
/// [MessageListCore] is a simplified class that allows fetching a list of messages while exposing UI builders. /// [MessageListCore] is a simplified class that allows fetching a list of
/// messages while exposing UI builders.
///
/// A [MessageListController] is used to paginate data. /// A [MessageListController] is used to paginate data.
/// ///
/// ```dart /// ```dart
@@ -50,12 +53,14 @@ import 'stream_channel.dart';
/// ``` /// ```
/// ///
/// ///
/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channels. /// Make sure to have a [StreamChannel] ancestor in order to provide the
/// information about the channels.
///
/// The widget uses a [ListView.custom] to render the list of channels. /// The widget uses a [ListView.custom] to render the list of channels.
/// ///
class MessageListCore extends StatefulWidget { class MessageListCore extends StatefulWidget {
/// Instantiate a new [MessageListView]. /// Instantiate a new [MessageListView].
MessageListCore({ const MessageListCore({
Key key, Key key,
@required this.loadingBuilder, @required this.loadingBuilder,
@required this.emptyBuilder, @required this.emptyBuilder,
@@ -65,10 +70,16 @@ class MessageListCore extends StatefulWidget {
this.parentMessage, this.parentMessage,
this.messageListController, this.messageListController,
this.messageFilter, this.messageFilter,
}) : assert(loadingBuilder != null), }) : assert(loadingBuilder != null, 'loadingBuilder should not be null'),
assert(emptyBuilder != null), assert(emptyBuilder != null, 'emptyBuilder should not be null'),
assert(messageListBuilder != null), assert(
assert(errorWidgetBuilder != null), messageListBuilder != null,
'messageListBuilder should not be null',
),
assert(
errorWidgetBuilder != null,
'errorWidgetBuilder should not be null',
),
super(key: key); super(key: key);
/// A [MessageListController] allows pagination. /// A [MessageListController] allows pagination.
@@ -84,12 +95,15 @@ class MessageListCore extends StatefulWidget {
/// Function used to build an empty widget /// Function used to build an empty widget
final WidgetBuilder emptyBuilder; final WidgetBuilder emptyBuilder;
/// Callback triggered when an error occurs while performing the given request. /// Callback triggered when an error occurs while performing the given
/// This parameter can be used to display an error message to users in the event /// request.
/// of a connection failure. ///
/// This parameter can be used to display an error message to users in the
/// event of a connection failure.
final ErrorBuilder errorWidgetBuilder; final ErrorBuilder errorWidgetBuilder;
/// If true will show a scroll to bottom message when there are new messages and the scroll offset is not zero /// If true will show a scroll to bottom message when there are new messages
/// and the scroll offset is not zero.
final bool showScrollToBottom; final bool showScrollToBottom;
/// If the current message belongs to a `thread`, this property represents the /// If the current message belongs to a `thread`, this property represents the
@@ -100,32 +114,28 @@ class MessageListCore extends StatefulWidget {
final bool Function(Message) messageFilter; final bool Function(Message) messageFilter;
@override @override
_MessageListCoreState createState() => _MessageListCoreState(); MessageListCoreState createState() => MessageListCoreState();
} }
class _MessageListCoreState extends State<MessageListCore> { /// The current state of the [MessageListCore].
StreamChannelState streamChannel; class MessageListCoreState extends State<MessageListCore> {
StreamChannelState _streamChannel;
bool get _upToDate => streamChannel.channel.state.isUpToDate; bool get _upToDate => _streamChannel.channel.state.isUpToDate;
bool get _isThreadConversation => widget.parentMessage != null; bool get _isThreadConversation => widget.parentMessage != null;
OwnUser get _currentUser => streamChannel.channel.client.state.user; OwnUser get _currentUser => _streamChannel.channel.client.state.user;
int initialIndex; var _messages = <Message>[];
double initialAlignment;
List<Message> messages = <Message>[];
bool initialMessageHighlightComplete = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final messagesStream = _isThreadConversation final messagesStream = _isThreadConversation
? streamChannel.channel.state.threadsStream ? _streamChannel.channel.state.threadsStream
.where((threads) => threads.containsKey(widget.parentMessage.id)) .where((threads) => threads.containsKey(widget.parentMessage.id))
.map((threads) => threads[widget.parentMessage.id]) .map((threads) => threads[widget.parentMessage.id])
: streamChannel.channel.state?.messagesStream; : _streamChannel.channel.state?.messagesStream;
bool defaultFilter(Message m) { bool defaultFilter(Message m) {
final isMyMessage = m.user.id == _currentUser.id; final isMyMessage = m.user.id == _currentUser.id;
@@ -138,10 +148,10 @@ class _MessageListCoreState extends State<MessageListCore> {
stream: messagesStream?.map((messages) => stream: messagesStream?.map((messages) =>
messages?.where(widget.messageFilter ?? defaultFilter)?.toList()), messages?.where(widget.messageFilter ?? defaultFilter)?.toList()),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (snapshot.hasError) {
return widget.loadingBuilder(context);
} else if (snapshot.hasError) {
return widget.errorWidgetBuilder(context, snapshot.error); return widget.errorWidgetBuilder(context, snapshot.error);
} else if (!snapshot.hasData) {
return widget.loadingBuilder(context);
} else { } else {
final messageList = snapshot.data?.reversed?.toList() ?? []; final messageList = snapshot.data?.reversed?.toList() ?? [];
if (messageList.isEmpty && !_isThreadConversation) { if (messageList.isEmpty && !_isThreadConversation) {
@@ -149,29 +159,32 @@ class _MessageListCoreState extends State<MessageListCore> {
return widget.emptyBuilder(context); return widget.emptyBuilder(context);
} }
} else { } else {
messages = messageList; _messages = messageList;
} }
return widget.messageListBuilder(context, messages); return widget.messageListBuilder(context, _messages);
} }
}, },
); );
} }
/// Fetches more messages with updated pagination and updates the widget.
///
/// Optionally pass the fetch direction, defaults to [QueryDirection.bottom]
Future<void> paginateData( Future<void> paginateData(
{QueryDirection direction = QueryDirection.bottom}) { {QueryDirection direction = QueryDirection.bottom}) {
if (!_isThreadConversation) { if (!_isThreadConversation) {
return streamChannel.queryMessages(direction: direction); return _streamChannel.queryMessages(direction: direction);
} else { } else {
return streamChannel.getReplies(widget.parentMessage.id); return _streamChannel.getReplies(widget.parentMessage.id);
} }
} }
@override @override
void initState() { void initState() {
streamChannel = StreamChannel.of(context); _streamChannel = StreamChannel.of(context);
if (_isThreadConversation) { if (_isThreadConversation) {
streamChannel.getReplies(widget.parentMessage.id); _streamChannel.getReplies(widget.parentMessage.id);
} }
if (widget.messageListController != null) { if (widget.messageListController != null) {
@@ -184,7 +197,7 @@ class _MessageListCoreState extends State<MessageListCore> {
@override @override
void dispose() { void dispose() {
if (!_upToDate) { if (!_upToDate) {
streamChannel.reloadChannel(); _streamChannel.reloadChannel();
} }
super.dispose(); super.dispose();
} }
@@ -193,5 +206,5 @@ class _MessageListCoreState extends State<MessageListCore> {
/// Controller used for paginating data in [ChannelListView] /// Controller used for paginating data in [ChannelListView]
class MessageListController { class MessageListController {
/// Call this function to load further data /// Call this function to load further data
Function({QueryDirection direction}) paginateData; Future<void> Function({QueryDirection direction}) paginateData;
} }
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/stream_chat_core.dart';
import 'stream_chat_core.dart';
/// [MessageSearchBloc] is used to manage a list of messages with pagination. /// [MessageSearchBloc] is used to manage a list of messages with pagination.
/// This class can be used to load messages, perform queries, etc. /// This class can be used to load messages, perform queries, etc.
@@ -16,7 +15,7 @@ class MessageSearchBloc extends StatefulWidget {
const MessageSearchBloc({ const MessageSearchBloc({
Key key, Key key,
@required this.child, @required this.child,
}) : assert(child != null), }) : assert(child != null, 'Parameter child should not be null.'),
super(key: key); super(key: key);
/// The widget child /// The widget child
@@ -49,17 +48,16 @@ class MessageSearchBlocState extends State<MessageSearchBloc>
Stream<List<GetMessageResponse>> get messagesStream => Stream<List<GetMessageResponse>> get messagesStream =>
_messageResponses.stream; _messageResponses.stream;
final BehaviorSubject<List<GetMessageResponse>> _messageResponses = final _messageResponses = BehaviorSubject<List<GetMessageResponse>>();
BehaviorSubject();
final BehaviorSubject<bool> _queryMessagesLoadingController = final _queryMessagesLoadingController = BehaviorSubject.seeded(false);
BehaviorSubject.seeded(false);
/// The stream notifying the state of queryUsers call /// The stream notifying the state of queryUsers call
Stream<bool> get queryMessagesLoading => Stream<bool> get queryMessagesLoading =>
_queryMessagesLoadingController.stream; _queryMessagesLoadingController.stream;
/// Calls [StreamChatClient.search] updating [messageResponses] stream /// Calls [StreamChatClient.search] updating
/// [messagesStream] and [queryMessagesLoading] stream
Future<void> search({ Future<void> search({
Map<String, dynamic> filter, Map<String, dynamic> filter,
Map<String, dynamic> messageFilter, Map<String, dynamic> messageFilter,
@@ -67,33 +65,13 @@ class MessageSearchBlocState extends State<MessageSearchBloc>
String query, String query,
PaginationParams pagination, PaginationParams pagination,
}) async { }) async {
_messageResponses.add(null); final client = StreamChatCore.of(context).client;
try {
final messages = await _search(
filter: filter,
messageFilter: messageFilter,
sort: sort,
query: query,
pagination: pagination,
);
_messageResponses.add(messages.results);
} catch (err, stk) {
_messageResponses.addError(err, stk);
}
}
/// Calls [StreamChatClient.search] updating [queryMessagesLoading] stream if (_queryMessagesLoadingController.value == true) return;
Future<void> loadMore({
Map<String, dynamic> filter, if (_messageResponses.hasValue) {
Map<String, dynamic> messageFilter,
List<SortOption> sort,
String query,
PaginationParams pagination,
}) async {
if (_queryMessagesLoadingController.value == true) {
return;
}
_queryMessagesLoadingController.add(true); _queryMessagesLoadingController.add(true);
}
try { try {
final clear = pagination == null || final clear = pagination == null ||
pagination.offset == null || pagination.offset == null ||
@@ -101,12 +79,12 @@ class MessageSearchBlocState extends State<MessageSearchBloc>
final oldMessages = List<GetMessageResponse>.from(messageResponses ?? []); final oldMessages = List<GetMessageResponse>.from(messageResponses ?? []);
final messages = await _search( final messages = await client.search(
filter: filter, filter,
messageFilter: messageFilter,
sort: sort, sort: sort,
query: query, query: query,
pagination: pagination, paginationParams: pagination,
messageFilters: messageFilter,
); );
if (clear) { if (clear) {
@@ -115,28 +93,16 @@ class MessageSearchBlocState extends State<MessageSearchBloc>
final temp = oldMessages + messages.results; final temp = oldMessages + messages.results;
_messageResponses.add(temp); _messageResponses.add(temp);
} }
if (_messageResponses.hasValue && _queryMessagesLoadingController.value) {
_queryMessagesLoadingController.add(false); _queryMessagesLoadingController.add(false);
} catch (err, stackTrace) { }
_queryMessagesLoadingController.addError(err, stackTrace); } catch (e, stk) {
if (_messageResponses.hasValue) {
_queryMessagesLoadingController.addError(e, stk);
} else {
_messageResponses.addError(e, stk);
} }
} }
Future<SearchMessagesResponse> _search({
Map<String, dynamic> filter,
Map<String, dynamic> messageFilter,
List<SortOption> sort,
String query,
PaginationParams pagination,
}) {
final client = StreamChatCore.of(context).client;
return client.search(
filter,
sort: sort,
query: query,
paginationParams: pagination,
messageFilters: messageFilter,
);
} }
@override @override
@@ -1,12 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/message_search_bloc.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart';
import 'message_search_bloc.dart';
/// ///
/// [MessageSearchListCore] is a simplified class that allows searching for messages across channels while exposing UI builders. /// [MessageSearchListCore] is a simplified class that allows searching for
/// messages across channels while exposing UI builders.
/// A [MessageSearchListController] is used to load and paginate data. /// A [MessageSearchListController] is used to load and paginate data.
/// ///
/// ```dart /// ```dart
@@ -28,7 +30,8 @@ import 'message_search_bloc.dart';
/// } /// }
/// ``` /// ```
/// ///
/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the information about the messages. /// Make sure to have a [MessageSearchBloc] ancestor in order to provide the
/// information about the messages.
/// The widget uses a [ListView.separated] to render the list of messages. /// The widget uses a [ListView.separated] to render the list of messages.
/// ///
class MessageSearchListCore extends StatefulWidget { class MessageSearchListCore extends StatefulWidget {
@@ -50,14 +53,16 @@ class MessageSearchListCore extends StatefulWidget {
this.paginationParams, this.paginationParams,
this.messageFilters, this.messageFilters,
this.messageSearchListController, this.messageSearchListController,
}) : assert(emptyBuilder != null), }) : assert(emptyBuilder != null, 'emptyBuilder should not be null'),
assert(errorBuilder != null), assert(errorBuilder != null, 'errorBuilder should not be null'),
assert(loadingBuilder != null), assert(loadingBuilder != null, 'loadingBuilder should not be null'),
assert(childBuilder != null), assert(childBuilder != null, 'childBuilder should not be null'),
super(key: key); super(key: key);
/// A [MessageSearchListController] allows reloading and pagination. /// A [MessageSearchListController] allows reloading and pagination.
/// Use [MessageSearchListController.loadData] and [MessageSearchListController.paginateData] respectively for reloading and pagination. /// Use [MessageSearchListController.loadData] and
/// [MessageSearchListController.paginateData] respectively for reloading and
/// pagination.
final MessageSearchListController messageSearchListController; final MessageSearchListController messageSearchListController;
/// Message String to search on /// Message String to search on
@@ -69,9 +74,10 @@ class MessageSearchListCore extends StatefulWidget {
final Map<String, dynamic> filters; final Map<String, dynamic> filters;
/// The sorting used for the channels matching the filters. /// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can be provided. /// Sorting is based on field and direction, multiple sorting options can be
/// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. /// provided.
/// Direction can be ascending or descending. /// You can sort based on last_updated, last_message_at, updated_at, created_
/// at or member_count. Direction can be ascending or descending.
final List<SortOption> sortOptions; final List<SortOption> sortOptions;
/// Pagination parameters /// Pagination parameters
@@ -98,22 +104,15 @@ class MessageSearchListCore extends StatefulWidget {
final WidgetBuilder loadingBuilder; final WidgetBuilder loadingBuilder;
@override @override
_MessageSearchListCoreState createState() => _MessageSearchListCoreState(); MessageSearchListCoreState createState() => MessageSearchListCoreState();
} }
class _MessageSearchListCoreState extends State<MessageSearchListCore> { /// The current state of the [MessageSearchListCore].
class MessageSearchListCoreState extends State<MessageSearchListCore> {
@override @override
void initState() { void didChangeDependencies() {
super.initState(); super.didChangeDependencies();
final messageSearchBloc = MessageSearchBloc.of(context); loadData();
messageSearchBloc.search(
filter: widget.filters,
sort: widget.sortOptions,
query: widget.messageQuery,
pagination: widget.paginationParams,
messageFilter: widget.messageFilters,
);
if (widget.messageSearchListController != null) { if (widget.messageSearchListController != null) {
widget.messageSearchListController.loadData = loadData; widget.messageSearchListController.loadData = loadData;
widget.messageSearchListController.paginateData = paginateData; widget.messageSearchListController.paginateData = paginateData;
@@ -126,37 +125,28 @@ class _MessageSearchListCoreState extends State<MessageSearchListCore> {
return _buildListView(messageSearchBloc); return _buildListView(messageSearchBloc);
} }
Widget _buildListView(MessageSearchBlocState messageSearchBloc) { Widget _buildListView(MessageSearchBlocState messageSearchBloc) =>
return StreamBuilder<List<GetMessageResponse>>( StreamBuilder<List<GetMessageResponse>>(
stream: messageSearchBloc.messagesStream, stream: messageSearchBloc.messagesStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
if (snapshot.error is Error) {
print((snapshot.error as Error).stackTrace);
}
return widget.errorBuilder(context, snapshot.error); return widget.errorBuilder(context, snapshot.error);
} }
if (!snapshot.hasData) { if (!snapshot.hasData) {
return widget.loadingBuilder(context); return widget.loadingBuilder(context);
} }
final items = snapshot.data; final items = snapshot.data;
if (items.isEmpty) { if (items.isEmpty) {
return widget.emptyBuilder(context); return widget.emptyBuilder(context);
} }
return widget.childBuilder(snapshot.data); return widget.childBuilder(snapshot.data);
}, },
); );
}
void loadData() { /// Fetches initial messages and updates the widget
Future<void> loadData() {
final messageSearchBloc = MessageSearchBloc.of(context); final messageSearchBloc = MessageSearchBloc.of(context);
return messageSearchBloc.search(
messageSearchBloc.search(
filter: widget.filters, filter: widget.filters,
sort: widget.sortOptions, sort: widget.sortOptions,
query: widget.messageQuery, query: widget.messageQuery,
@@ -165,10 +155,10 @@ class _MessageSearchListCoreState extends State<MessageSearchListCore> {
); );
} }
void paginateData() { /// Fetches more messages with updated pagination and updates the widget
Future<void> paginateData() {
final messageSearchBloc = MessageSearchBloc.of(context); final messageSearchBloc = MessageSearchBloc.of(context);
return messageSearchBloc.search(
messageSearchBloc.loadMore(
filter: widget.filters, filter: widget.filters,
sort: widget.sortOptions, sort: widget.sortOptions,
pagination: widget.paginationParams.copyWith( pagination: widget.paginationParams.copyWith(
@@ -184,19 +174,12 @@ class _MessageSearchListCoreState extends State<MessageSearchListCore> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.filters?.toString() != oldWidget.filters?.toString() || if (widget.filters?.toString() != oldWidget.filters?.toString() ||
jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) ||
widget.paginationParams?.toJson()?.toString() !=
oldWidget.paginationParams?.toJson()?.toString() ||
widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() ||
widget.messageFilters?.toString() != widget.messageFilters?.toString() !=
oldWidget.messageFilters?.toString()) { oldWidget.messageFilters?.toString() ||
final messageSearchBloc = MessageSearchBloc.of(context); widget.paginationParams?.toJson()?.toString() !=
messageSearchBloc.search( oldWidget.paginationParams?.toJson()?.toString()) {
filter: widget.filters, loadData();
sort: widget.sortOptions,
query: widget.messageQuery,
pagination: widget.paginationParams,
messageFilter: widget.messageFilters,
);
} }
} }
} }
@@ -204,8 +187,8 @@ class _MessageSearchListCoreState extends State<MessageSearchListCore> {
/// Controller used for paginating data in [ChannelListView] /// Controller used for paginating data in [ChannelListView]
class MessageSearchListController { class MessageSearchListController {
/// Call this function to reload data /// Call this function to reload data
VoidCallback loadData; AsyncCallback loadData;
/// Call this function to load further data /// Call this function to load further data
VoidCallback paginateData; AsyncCallback paginateData;
} }
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
@@ -25,8 +26,8 @@ class StreamChannel extends StatefulWidget {
@required this.channel, @required this.channel,
this.showLoading = true, this.showLoading = true,
this.initialMessageId, this.initialMessageId,
}) : assert(child != null), }) : assert(child != null, 'Child should not be null'),
assert(channel != null), assert(channel != null, 'Channel should not be null'),
super(key: key); super(key: key);
// ignore: public_member_api_docs // ignore: public_member_api_docs
@@ -49,7 +50,8 @@ class StreamChannel extends StatefulWidget {
if (streamChannelState == null) { if (streamChannelState == null) {
throw Exception( throw Exception(
'You must have a StreamChannel widget at the top of your widget tree'); 'You must have a StreamChannel widget at the top of your widget tree',
);
} }
return streamChannelState; return streamChannelState;
@@ -204,17 +206,15 @@ class StreamChannelState extends State<StreamChannel> {
int before = 20, int before = 20,
int after = 20, int after = 20,
bool preferOffline = false, bool preferOffline = false,
}) { }) =>
return queryAtMessage( _queryAtMessage(
messageId: messageId, messageId: messageId,
before: before, before: before,
after: after, after: after,
preferOffline: preferOffline, preferOffline: preferOffline,
); );
}
/// Future<void> _queryAtMessage({
Future<void> queryAtMessage({
String messageId, String messageId,
int before = 20, int before = 20,
int after = 20, int after = 20,
@@ -254,15 +254,14 @@ class StreamChannelState extends State<StreamChannel> {
String messageId, { String messageId, {
int limit = 20, int limit = 20,
bool preferOffline = false, bool preferOffline = false,
}) { }) =>
return channel.query( channel.query(
messagesPagination: PaginationParams( messagesPagination: PaginationParams(
lessThan: messageId, lessThan: messageId,
limit: limit, limit: limit,
), ),
preferOffline: preferOffline, preferOffline: preferOffline,
); );
}
/// ///
Future<ChannelState> queryAfterMessage( Future<ChannelState> queryAfterMessage(
@@ -297,7 +296,7 @@ class StreamChannelState extends State<StreamChannel> {
} }
/// Reloads the channel with latest message /// Reloads the channel with latest message
Future<void> reloadChannel() => queryAtMessage(before: 30); Future<void> reloadChannel() => _queryAtMessage(before: 30);
List<Future<bool>> _futures; List<Future<bool>> _futures;
@@ -305,8 +304,7 @@ class StreamChannelState extends State<StreamChannel> {
try { try {
await loadChannelAtMessage(initialMessageId); await loadChannelAtMessage(initialMessageId);
return true; return true;
} catch (e, stk) { } catch (_) {
print('Error: $e\nStack: $stk');
rethrow; rethrow;
} }
} }
@@ -349,9 +347,6 @@ class StreamChannelState extends State<StreamChannel> {
], ],
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
if (snapshot.error is Error) {
print((snapshot.error as Error).stackTrace);
}
var message = snapshot.error.toString(); var message = snapshot.error.toString();
if (snapshot.error is DioError) { if (snapshot.error is DioError) {
final dioError = snapshot.error as DioError; final dioError = snapshot.error as DioError;
@@ -361,14 +356,13 @@ class StreamChannelState extends State<StreamChannel> {
message = 'Check your connection and retry'; message = 'Check your connection and retry';
} }
} }
return Center( return Center(child: Text(message));
child: Text(message),
);
} }
final initialized = snapshot.data[0]; final initialized = snapshot.data[0];
// ignore: avoid_bool_literals_in_conditional_expressions
final dataLoaded = initialMessageId == null ? true : snapshot.data[1]; final dataLoaded = initialMessageId == null ? true : snapshot.data[1];
if (widget.showLoading && (!initialized || !dataLoaded)) { if (widget.showLoading && (!initialized || !dataLoaded)) {
return Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
@@ -3,13 +3,12 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart';
import 'typedef.dart';
/// Widget used to provide information about the chat to the widget tree. /// Widget used to provide information about the chat to the widget tree.
/// This Widget is used to react to life cycle changes and system updates. /// This Widget is used to react to life cycle changes and system updates.
/// When the app goes into the background, the websocket connection is kept alive /// When the app goes into the background, the websocket connection is kept
/// for two minutes before being terminated. /// alive for two minutes before being terminated.
/// ///
/// Conversely, when app is resumed or restarted, a new connection is initiated. /// Conversely, when app is resumed or restarted, a new connection is initiated.
/// ///
@@ -36,16 +35,16 @@ import 'typedef.dart';
class StreamChatCore extends StatefulWidget { class StreamChatCore extends StatefulWidget {
/// Constructor used for creating a new instance of [StreamChatCore]. /// Constructor used for creating a new instance of [StreamChatCore].
/// ///
/// [StreamChatCore] is a stateful widget which reacts to system events and updates /// [StreamChatCore] is a stateful widget which reacts to system events and
/// Stream's connection status accordingly. /// updates Stream's connection status accordingly.
StreamChatCore({ const StreamChatCore({
Key key, Key key,
@required this.client, @required this.client,
@required this.child, @required this.child,
this.onBackgroundEventReceived, this.onBackgroundEventReceived,
this.backgroundKeepAlive = const Duration(minutes: 1), this.backgroundKeepAlive = const Duration(minutes: 1),
}) : assert(client != null), }) : assert(client != null, 'Stream Chat Client should not be null'),
assert(child != null), assert(child != null, 'Child should not be null'),
super(key: key); super(key: key);
/// Instance of Stream Chat Client containing information about the current /// Instance of Stream Chat Client containing information about the current
@@ -55,7 +54,8 @@ class StreamChatCore extends StatefulWidget {
/// Widget descendant. /// Widget descendant.
final Widget child; final Widget child;
/// The amount of time that will pass before disconnecting the client in the background /// The amount of time that will pass before disconnecting the client in
/// the background
final Duration backgroundKeepAlive; final Duration backgroundKeepAlive;
/// Handler called whenever the [client] receives a new [Event] while the app /// Handler called whenever the [client] receives a new [Event] while the app
@@ -90,15 +90,13 @@ class StreamChatCoreState extends State<StreamChatCore>
Timer _disconnectTimer; Timer _disconnectTimer;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => widget.child;
return widget.child;
}
/// The current user /// The current user
User get user => widget.client.state.user; User get user => client.state?.user;
/// The current user as a stream /// The current user as a stream
Stream<User> get userStream => widget.client.state.userStream; Stream<User> get userStream => client.state?.userStream;
@override @override
void initState() { void initState() {
@@ -110,21 +108,25 @@ class StreamChatCoreState extends State<StreamChatCore>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (client.state?.user != null) { if (user != null) {
if (state == AppLifecycleState.paused) { if (state == AppLifecycleState.paused) {
if (widget.onBackgroundEventReceived != null) { if (widget.onBackgroundEventReceived == null) {
_eventSubscription = client.disconnect();
client.on().listen(widget.onBackgroundEventReceived); return;
_disconnectTimer = Timer( }
widget.backgroundKeepAlive, _eventSubscription = client.on().listen(
client.disconnect, widget.onBackgroundEventReceived,
); );
} else {
void onTimerComplete() {
_eventSubscription.cancel();
client.disconnect(); client.disconnect();
} }
_disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete);
} else if (state == AppLifecycleState.resumed) { } else if (state == AppLifecycleState.resumed) {
_eventSubscription?.cancel();
if (_disconnectTimer?.isActive == true) { if (_disconnectTimer?.isActive == true) {
_eventSubscription.cancel();
_disconnectTimer.cancel(); _disconnectTimer.cancel();
} else { } else {
if (client.wsConnectionStatus == ConnectionStatus.disconnected) { if (client.wsConnectionStatus == ConnectionStatus.disconnected) {
@@ -2,8 +2,8 @@ import 'package:flutter/widgets.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
/// A signature for a callback which exposes an error and returns a function. /// A signature for a callback which exposes an error and returns a function.
/// This Callback can be used in cases where an API failure occurs and the widget /// This Callback can be used in cases where an API failure occurs and the
/// is unable to render data. /// widget is unable to render data.
typedef ErrorBuilder = Widget Function(BuildContext context, Object error); typedef ErrorBuilder = Widget Function(BuildContext context, Object error);
/// A Signature for a handler function which will expose a [event]. /// A Signature for a handler function which will expose a [event].
@@ -1,11 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/users_bloc.dart'; import 'package:stream_chat_flutter_core/src/users_bloc.dart';
/// ///
/// [UserListCore] is a simplified class that allows fetching users while exposing UI builders. /// [UserListCore] is a simplified class that allows fetching users while
/// exposing UI builders.
/// A [UserListController] is used to load and paginate data. /// A [UserListController] is used to load and paginate data.
/// ///
/// ```dart /// ```dart
@@ -50,34 +52,35 @@ import 'package:stream_chat_flutter_core/src/users_bloc.dart';
/// [UsersBloc] must be the ancestor of this widget. This is necessary since /// [UsersBloc] must be the ancestor of this widget. This is necessary since
/// [UserListCore] depends on functionality contained within [UsersBloc]. /// [UserListCore] depends on functionality contained within [UsersBloc].
/// ///
/// The parameters [listBuilder], [loadingBuilder], [emptyBuilder] and [errorBuilder] must all be supplied /// The parameters [listBuilder], [loadingBuilder], [emptyBuilder] and
/// and not null. /// [errorBuilder] must all be supplied and not null.
class UserListCore extends StatefulWidget { class UserListCore extends StatefulWidget {
/// Instantiate a new [UserListCore] /// Instantiate a new [UserListCore]
const UserListCore({ const UserListCore({
Key key,
@required this.errorBuilder, @required this.errorBuilder,
@required this.emptyBuilder, @required this.emptyBuilder,
@required this.loadingBuilder, @required this.loadingBuilder,
@required this.listBuilder, @required this.listBuilder,
Key key,
this.filter, this.filter,
this.options, this.options,
this.sort, this.sort,
this.pagination, this.pagination,
this.groupAlphabetically = false, this.groupAlphabetically = false,
this.userListController, this.userListController,
}) : assert(errorBuilder != null), }) : assert(errorBuilder != null, ''),
assert(emptyBuilder != null), assert(emptyBuilder != null, ''),
assert(loadingBuilder != null), assert(loadingBuilder != null, ''),
assert(listBuilder != null), assert(listBuilder != null, ''),
super(key: key); super(key: key);
/// A [UserListController] allows reloading and pagination. /// A [UserListController] allows reloading and pagination.
/// Use [UserListController.loadData] and [UserListController.paginateData] respectively for reloading and pagination. /// Use [UserListController.loadData] and [UserListController.paginateData]
/// respectively for reloading and pagination.
final UserListController userListController; final UserListController userListController;
/// The builder that will be used in case of error /// The builder that will be used in case of error
final Widget Function(Error error) errorBuilder; final Widget Function(Object error) errorBuilder;
/// The builder that will be used to build the list /// The builder that will be used to build the list
final Widget Function(BuildContext context, List<ListItem> users) listBuilder; final Widget Function(BuildContext context, List<ListItem> users) listBuilder;
@@ -100,9 +103,9 @@ class UserListCore extends StatefulWidget {
final Map<String, dynamic> options; final Map<String, dynamic> options;
/// The sorting used for the channels matching the filters. /// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can be provided. /// Sorting is based on field and direction, multiple sorting options can be
/// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. /// provided. You can sort based on last_updated, last_message_at, updated_at,
/// Direction can be ascending or descending. /// created_at or member_count. Direction can be ascending or descending.
final List<SortOption> sort; final List<SortOption> sort;
/// Pagination parameters /// Pagination parameters
@@ -117,22 +120,16 @@ class UserListCore extends StatefulWidget {
final bool groupAlphabetically; final bool groupAlphabetically;
@override @override
_UserListCoreState createState() => _UserListCoreState(); UserListCoreState createState() => UserListCoreState();
} }
class _UserListCoreState extends State<UserListCore> /// The current state of the [UserListCore].
class UserListCoreState extends State<UserListCore>
with WidgetsBindingObserver { with WidgetsBindingObserver {
@override @override
void initState() { void didChangeDependencies() {
super.initState(); super.didChangeDependencies();
final usersBloc = UsersBloc.of(context); loadData();
usersBloc.queryUsers(
filter: widget.filter,
sort: widget.sort,
pagination: widget.pagination,
options: widget.options,
);
if (widget.userListController != null) { if (widget.userListController != null) {
widget.userListController.loadData = loadData; widget.userListController.loadData = loadData;
widget.userListController.paginateData = paginateData; widget.userListController.paginateData = paginateData;
@@ -142,22 +139,22 @@ class _UserListCoreState extends State<UserListCore>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final _usersBloc = UsersBloc.of(context); final _usersBloc = UsersBloc.of(context);
return _buildListView(_usersBloc); return _buildListView(_usersBloc);
} }
bool get isListAlreadySorted => bool get _isListAlreadySorted =>
widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false;
Stream<List<ListItem>> _buildUserStream( Stream<List<ListItem>> _buildUserStream(
UsersBlocState usersBlocState, UsersBlocState usersBlocState,
) { ) =>
return usersBlocState.usersStream.map( usersBlocState.usersStream.map(
(users) { (users) {
if (widget.groupAlphabetically) { if (widget.groupAlphabetically) {
var temp = users; var temp = users;
if (!isListAlreadySorted) { if (!_isListAlreadySorted) {
temp = users..sort((curr, next) => curr.name.compareTo(next.name)); temp = users
..sort((curr, next) => curr.name.compareTo(next.name));
} }
final groupedUsers = <String, List<User>>{}; final groupedUsers = <String, List<User>>{};
for (final e in temp) { for (final e in temp) {
@@ -166,53 +163,40 @@ class _UserListCoreState extends State<UserListCore>
} }
final items = <ListItem>[]; final items = <ListItem>[];
for (final key in groupedUsers.keys) { for (final key in groupedUsers.keys) {
items.add(ListHeaderItem(key)); items
items.addAll(groupedUsers[key].map((e) => ListUserItem(e))); ..add(ListHeaderItem(key))
..addAll(groupedUsers[key].map((e) => ListUserItem(e)));
} }
return items; return items;
} }
return users.map((e) => ListUserItem(e)).toList(); return users.map((e) => ListUserItem(e)).toList();
}, },
); );
}
StreamBuilder<List<ListItem>> _buildListView( StreamBuilder<List<ListItem>> _buildListView(
UsersBlocState usersBlocState, UsersBlocState usersBlocState,
) { ) =>
return StreamBuilder( StreamBuilder(
stream: _buildUserStream(usersBlocState), stream: _buildUserStream(usersBlocState),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
if (snapshot.error is Error) {
print((snapshot.error as Error).stackTrace);
}
return widget.errorBuilder(snapshot.error); return widget.errorBuilder(snapshot.error);
} }
if (!snapshot.hasData) { if (!snapshot.hasData) {
return widget.loadingBuilder(context); return widget.loadingBuilder(context);
} }
final items = snapshot.data; final items = snapshot.data;
if (items.isEmpty) { if (items.isEmpty) {
return widget.emptyBuilder(context); return widget.emptyBuilder(context);
} }
if (items.isEmpty) {
return widget.emptyBuilder(context);
}
return widget.listBuilder(context, items); return widget.listBuilder(context, items);
}, },
); );
}
void loadData() { // ignore: public_member_api_docs
Future<void> loadData() {
final _usersBloc = UsersBloc.of(context); final _usersBloc = UsersBloc.of(context);
return _usersBloc.queryUsers(
_usersBloc.queryUsers(
filter: widget.filter, filter: widget.filter,
sort: widget.sort, sort: widget.sort,
pagination: widget.pagination, pagination: widget.pagination,
@@ -220,10 +204,10 @@ class _UserListCoreState extends State<UserListCore>
); );
} }
void paginateData() { // ignore: public_member_api_docs
Future<void> paginateData() {
final _usersBloc = UsersBloc.of(context); final _usersBloc = UsersBloc.of(context);
return _usersBloc.queryUsers(
_usersBloc.queryUsers(
filter: widget.filter, filter: widget.filter,
sort: widget.sort, sort: widget.sort,
pagination: widget.pagination.copyWith( pagination: widget.pagination.copyWith(
@@ -238,29 +222,23 @@ class _UserListCoreState extends State<UserListCore>
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.filter?.toString() != oldWidget.filter?.toString() || if (widget.filter?.toString() != oldWidget.filter?.toString() ||
jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) ||
widget.options?.toString() != oldWidget.options?.toString() ||
widget.pagination?.toJson()?.toString() != widget.pagination?.toJson()?.toString() !=
oldWidget.pagination?.toJson()?.toString() || oldWidget.pagination?.toJson()?.toString()) {
widget.options?.toString() != oldWidget.options?.toString()) { loadData();
final usersBloc = UsersBloc.of(context);
usersBloc.queryUsers(
filter: widget.filter,
sort: widget.sort,
pagination: widget.pagination,
options: widget.options,
);
} }
} }
} }
/// Represents an item in a the user stream list. /// Represents an item in a the user stream list.
/// Header items are prefixed with the key `HEADER` While users are prefixed with /// Header items are prefixed with the key `HEADER` While users are prefixed
/// `USER`. /// with `USER`.
abstract class ListItem { abstract class ListItem {
// ignore: public_member_api_docs /// Unique key per list item
String get key { String get key {
if (this is ListHeaderItem) { if (this is ListHeaderItem) {
final header = (this as ListHeaderItem).heading; final header = (this as ListHeaderItem).heading;
return 'HEADER-$header'; return 'HEADER-${header.toLowerCase()}';
} }
if (this is ListUserItem) { if (this is ListUserItem) {
final user = (this as ListUserItem).user; final user = (this as ListUserItem).user;
@@ -269,7 +247,8 @@ abstract class ListItem {
return null; return null;
} }
// ignore: public_member_api_docs /// Helper function to build widget based on ListItem type
// ignore: missing_return
Widget when({ Widget when({
@required Widget Function(String heading) headerItem, @required Widget Function(String heading) headerItem,
@required Widget Function(User user) userItem, @required Widget Function(User user) userItem,
@@ -280,33 +259,32 @@ abstract class ListItem {
if (this is ListUserItem) { if (this is ListUserItem) {
return userItem((this as ListUserItem).user); return userItem((this as ListUserItem).user);
} }
return SizedBox();
} }
} }
// ignore: public_member_api_docs /// Header Item
class ListHeaderItem extends ListItem { class ListHeaderItem extends ListItem {
// ignore: public_member_api_docs /// Constructs a new [ListHeaderItem]
final String heading;
// ignore: public_member_api_docs
ListHeaderItem(this.heading); ListHeaderItem(this.heading);
/// Heading used to build the item.
final String heading;
} }
// ignore: public_member_api_docs /// User Item
class ListUserItem extends ListItem { class ListUserItem extends ListItem {
// ignore: public_member_api_docs /// Constructs a new [ListUserItem]
final User user;
// ignore: public_member_api_docs
ListUserItem(this.user); ListUserItem(this.user);
/// [User] used to build the item.
final User user;
} }
/// Controller used for paginating data in [ChannelListView] /// Controller used for paginating data in [ChannelListView]
class UserListController { class UserListController {
/// Call this function to reload data /// Call this function to reload data
VoidCallback loadData; AsyncCallback loadData;
/// Call this function to load further data /// Call this function to load further data
VoidCallback paginateData; AsyncCallback paginateData;
} }
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'stream_chat_core.dart';
/// Widget dedicated to the management of a users list with pagination. /// Widget dedicated to the management of a users list with pagination.
/// ///
@@ -14,9 +14,12 @@ class UsersBloc extends StatefulWidget {
/// Instantiate a new [UsersBloc]. The parameter [child] must be supplied and /// Instantiate a new [UsersBloc]. The parameter [child] must be supplied and
/// not null. /// not null.
const UsersBloc({ const UsersBloc({
Key key,
@required this.child, @required this.child,
}) : assert(child != null), Key key,
}) : assert(
child != null,
'When constructing a UsersBloc, the parameter '
'child should not be null.'),
super(key: key); super(key: key);
/// The widget child /// The widget child
@@ -48,10 +51,9 @@ class UsersBlocState extends State<UsersBloc>
/// The current users list as a stream /// The current users list as a stream
Stream<List<User>> get usersStream => _usersController.stream; Stream<List<User>> get usersStream => _usersController.stream;
final BehaviorSubject<List<User>> _usersController = BehaviorSubject(); final _usersController = BehaviorSubject<List<User>>();
final BehaviorSubject<bool> _queryUsersLoadingController = final _queryUsersLoadingController = BehaviorSubject.seeded(false);
BehaviorSubject.seeded(false);
/// The stream notifying the state of queryUsers call /// The stream notifying the state of queryUsers call
Stream<bool> get queryUsersLoading => _queryUsersLoadingController.stream; Stream<bool> get queryUsersLoading => _queryUsersLoadingController.stream;
@@ -67,11 +69,12 @@ class UsersBlocState extends State<UsersBloc>
}) async { }) async {
final client = StreamChatCore.of(context).client; final client = StreamChatCore.of(context).client;
if (client.state?.user == null || if (_queryUsersLoadingController.value == true) return;
_queryUsersLoadingController.value == true) {
return; if (_usersController.hasValue) {
}
_queryUsersLoadingController.add(true); _queryUsersLoadingController.add(true);
}
try { try {
final clear = pagination == null || final clear = pagination == null ||
pagination.offset == null || pagination.offset == null ||
@@ -92,10 +95,15 @@ class UsersBlocState extends State<UsersBloc>
final temp = oldUsers + usersResponse.users; final temp = oldUsers + usersResponse.users;
_usersController.add(temp); _usersController.add(temp);
} }
if (_usersController.hasValue && _queryUsersLoadingController.value) {
_queryUsersLoadingController.add(false); _queryUsersLoadingController.add(false);
} catch (err, stackTrace) { }
_queryUsersLoadingController.addError(err, stackTrace); } catch (e, stk) {
if (_usersController.hasValue) {
_queryUsersLoadingController.addError(e, stk);
} else {
_usersController.addError(e, stk);
}
} }
} }
@@ -1,14 +1,15 @@
library stream_chat_flutter_core; library stream_chat_flutter_core;
export 'src/channel_list_core.dart'; export 'package:stream_chat/stream_chat.dart';
export 'src/channel_list_core.dart' hide ChannelListCoreState;
export 'src/channels_bloc.dart'; export 'src/channels_bloc.dart';
export 'src/lazy_load_scroll_view.dart'; export 'src/lazy_load_scroll_view.dart';
export 'src/message_list_core.dart'; export 'src/message_list_core.dart' hide MessageListCoreState;
export 'src/message_search_bloc.dart'; export 'src/message_search_bloc.dart';
export 'src/message_search_list_core.dart'; export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
export 'src/stream_channel.dart'; export 'src/stream_channel.dart';
export 'src/stream_chat_core.dart'; export 'src/stream_chat_core.dart';
export 'src/user_list_core.dart';
export 'src/users_bloc.dart';
export 'src/typedef.dart'; export 'src/typedef.dart';
export 'package:stream_chat/stream_chat.dart'; export 'src/user_list_core.dart' hide UserListCoreState;
export 'src/users_bloc.dart';
@@ -1,7 +1,7 @@
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: 1.5.0 version: 1.5.1
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
@@ -10,15 +10,13 @@ environment:
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:
stream_chat: ^1.5.0
flutter: flutter:
sdk: flutter sdk: flutter
meta: ^1.2.4
rxdart: ^0.25.0 rxdart: ^0.25.0
stream_chat: ^1.5.1
dev_dependencies: dev_dependencies:
mockito: ^4.1.4
flutter_test: flutter_test:
sdk: flutter sdk: flutter
fake_async: ^1.1.0 mockito: ^4.1.3
pedantic: ^1.9.2
@@ -0,0 +1,504 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/src/channel_list_core.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart';
void main() {
const pagination = PaginationParams(offset: 0, limit: 3);
List<Channel> _generateChannels(
StreamChatClient client, {
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return Channel(
client,
'testType$index',
'testId$index',
{'extra_data_key': 'extra_data_value_$index'},
);
},
);
}
test(
'should throw assertion error in case listBuilder is null',
() {
final channelListCore = () => ChannelListCore(
listBuilder: null,
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(channelListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case loadingBuilder is null',
() {
final channelListCore = () => ChannelListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: null,
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(channelListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case emptyBuilder is null',
() {
final channelListCore = () => ChannelListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: null,
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(channelListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case errorBuilder is null',
() {
final channelListCore = () => ChannelListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: null,
);
expect(channelListCore, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should throw if ChannelListCore is used where ChannelsBloc is not present '
'in the widget tree',
(tester) async {
const channelListCoreKey = Key('channelListCore');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
await tester.pumpWidget(channelListCore);
expect(find.byKey(channelListCoreKey), findsNothing);
expect(tester.takeException(), isInstanceOf<Exception>());
},
);
testWidgets(
'should render ChannelListCore if used with ChannelsBloc as an ancestor',
(tester) async {
const channelListCoreKey = Key('channelListCore');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
);
expect(find.byKey(channelListCoreKey), findsOneWidget);
},
);
testWidgets(
'should assign loadData and paginateData callback to '
'ChannelListController if passed',
(tester) async {
const channelListCoreKey = Key('channelListCore');
final controller = ChannelListController();
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
channelListController: controller,
);
expect(controller.loadData, isNull);
expect(controller.paginateData, isNull);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
);
expect(find.byKey(channelListCoreKey), findsOneWidget);
expect(controller.loadData, isNotNull);
expect(controller.paginateData, isNotNull);
},
);
testWidgets(
'should build error widget if channelsBlocState.channelsStream emits error',
(tester) async {
const channelListCoreKey = Key('channelListCore');
const errorWidgetKey = Key('errorWidget');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) =>
Container(key: errorWidgetKey),
pagination: pagination,
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
const error = 'Error! Error! Error!';
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).thenThrow(error);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(errorWidgetKey), findsOneWidget);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).called(1);
},
);
testWidgets(
'should build empty widget if channelsBlocState.channelsStream emits empty data',
(tester) async {
const channelListCoreKey = Key('channelListCore');
const emptyWidgetKey = Key('emptyWidget');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey),
errorBuilder: (BuildContext context, Object error) => Offstage(),
pagination: pagination,
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
const channels = <Channel>[];
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).thenAnswer((_) => Stream.value(channels));
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(emptyWidgetKey), findsOneWidget);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).called(1);
},
);
testWidgets(
'should build list widget if channelsBlocState.channelsStream emits some data',
(tester) async {
const channelListCoreKey = Key('channelListCore');
const listWidgetKey = Key('listWidget');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, __) => Container(key: listWidgetKey),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
pagination: pagination,
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).thenAnswer((_) => Stream.value(channels));
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).called(1);
},
);
testWidgets(
'should build list widget with paginated data '
'on calling channelListCoreState.paginateData',
(tester) async {
const channelListCoreKey = Key('channelListCore');
const listWidgetKey = Key('listWidget');
final channelListCore = ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, channels) {
return Container(
key: listWidgetKey,
child: Text(
channels.map((e) => e.cid).join(','),
),
);
},
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
pagination: pagination,
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).thenAnswer((_) => Stream.value(channels));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: channelListCore,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).called(1);
final channelListCoreState = tester.state<ChannelListCoreState>(
find.byKey(channelListCoreKey),
);
final offset = channels.length;
final paginatedChannels = _generateChannels(mockClient, offset: offset);
final updatedPagination = pagination.copyWith(offset: offset);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: updatedPagination,
)).thenAnswer((_) => Stream.value(paginatedChannels));
await channelListCoreState.paginateData();
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(
find.text([
...channels,
...paginatedChannels,
].map((e) => e.cid).join(',')),
findsOneWidget,
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: updatedPagination,
)).called(1);
},
);
testWidgets(
'should rebuild ChannelListCore with updated widget data '
'on calling setState()',
(tester) async {
StateSetter _stateSetter;
int limit = pagination.limit;
const channelListCoreKey = Key('channelListCore');
const listWidgetKey = Key('listWidget');
ChannelListCore channelListCoreBuilder(int limit) => ChannelListCore(
key: channelListCoreKey,
listBuilder: (_, channels) {
return Container(
key: listWidgetKey,
child: Text(
channels.map((e) => e.cid).join(','),
),
);
},
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
pagination: pagination.copyWith(limit: limit),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).thenAnswer((_) => Stream.value(channels));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: ChannelsBloc(
child: StatefulBuilder(builder: (context, stateSetter) {
// Assigning stateSetter for rebuilding ChannelListCore
_stateSetter = stateSetter;
return channelListCoreBuilder(limit);
}),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: pagination,
)).called(1);
// Rebuilding ChannelListCore with new pagination limit
_stateSetter(() => limit = 6);
final updatedChannels = _generateChannels(mockClient, count: limit);
final updatedPagination = pagination.copyWith(limit: limit);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: updatedPagination,
)).thenAnswer((_) => Stream.value(updatedChannels));
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(
find.text(updatedChannels.map((e) => e.cid).join(',')),
findsOneWidget,
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: updatedPagination,
)).called(1);
},
);
}
@@ -0,0 +1,847 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'matchers/channel_matcher.dart';
import 'mocks.dart';
void main() {
List<Channel> _generateChannels(
StreamChatClient client, {
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return Channel(
client,
'testType$index',
'testId$index',
{'extra_data_key': 'extra_data_value_$index'},
);
},
);
}
test(
'should throw assertion error if child is null',
() async {
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = () => ChannelsBloc(
key: channelsBlocKey,
child: null,
);
expect(channelsBloc, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should throw if ChannelsBloc is used where StreamChat is not present in the widget tree',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
const childKey = Key('child');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(key: childKey),
);
await tester.pumpWidget(channelsBloc);
expect(find.byKey(channelsBlocKey), findsNothing);
expect(find.byKey(childKey), findsNothing);
expect(tester.takeException(), isInstanceOf<Exception>());
},
);
testWidgets(
'should render ChannelsBloc if used with StreamChatCore as an ancestor',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
const childKey = Key('child');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
expect(find.byKey(channelsBlocKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
},
);
testWidgets(
'channelsBlocState.queryChannels() should emit data through channelsStream',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
const childKey = Key('child');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Builder(
key: childKey,
builder: (context) {
return Offstage();
},
),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final offlineChannels = _generateChannels(mockClient);
final onlineChannels = _generateChannels(mockClient, offset: 3);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.fromIterable([offlineChannels, onlineChannels]),
);
channelsBlocState.queryChannels();
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(offlineChannels),
isSameChannelListAs(onlineChannels),
]),
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'channelsBlocState.channelsStream should emit error '
'if client.queryChannels() throws',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
const childKey = Key('child');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Builder(
key: childKey,
builder: (context) {
return Offstage();
},
),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final error = 'Error! Error! Error!';
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenThrow(error);
channelsBlocState.queryChannels();
await expectLater(
channelsBlocState.channelsStream,
emitsError(error),
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'calling channelsBlocState.queryChannels() again with an offset '
'should emit new data through channelsStream and also emit loading state '
'through queryChannelsLoading',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer((_) => Stream.value(channels));
channelsBlocState.queryChannels();
await expectLater(
channelsBlocState.channelsStream,
emits(isSameChannelListAs(channels)),
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final offset = channels.length;
final paginationParams = PaginationParams(offset: offset);
final newChannels = _generateChannels(mockClient, offset: offset);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: paginationParams,
)).thenAnswer(
(_) => Stream.value(newChannels),
);
channelsBlocState.queryChannels(paginationParams: paginationParams);
await Future.wait([
expectLater(
channelsBlocState.queryChannelsLoading,
emitsInOrder([true, false]),
),
expectLater(
channelsBlocState.channelsStream,
emits(isSameChannelListAs(channels + newChannels)),
),
]);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: paginationParams,
)).called(1);
},
);
testWidgets(
'calling channelsBlocState.queryChannels() again with an offset '
'should emit error through queryChannelsLoading if '
'client.queryChannels() throws',
(tester) async {
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
);
final mockClient = MockClient();
when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty());
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer((_) => Stream.value(channels));
channelsBlocState.queryChannels();
await expectLater(
channelsBlocState.channelsStream,
emits(isSameChannelListAs(channels)),
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final offset = channels.length;
final paginationParams = PaginationParams(offset: offset);
final error = 'Error! Error! Error!';
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: paginationParams,
)).thenThrow(error);
channelsBlocState.queryChannels(paginationParams: paginationParams);
await expectLater(
channelsBlocState.queryChannelsLoading,
emitsError(error),
);
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: paginationParams,
)).called(1);
},
);
group('event controller test', () {
StreamController<Event> eventController;
setUp(() {
eventController = StreamController<Event>.broadcast();
});
testWidgets(
'channel should get hide when EventType.channelHidden event is received',
(tester) async {
final mockClient = MockClient();
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.channelHidden,
)).thenAnswer((_) => eventController.stream);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final channelHiddenEvent = Event(
type: EventType.channelHidden,
cid: channels.first.cid,
);
eventController.add(channelHiddenEvent);
final newChannels = [...channels]
..removeWhere((it) => it.cid == channelHiddenEvent.cid);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(EventType.channelHidden)).called(1);
},
);
testWidgets(
'channel should get removed when EventType.channelDeleted or '
'EventType.notificationRemovedFromChannel, event is received',
(tester) async {
final mockClient = MockClient();
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.channelDeleted,
EventType.notificationRemovedFromChannel,
)).thenAnswer((_) => eventController.stream);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final channelDeletedOrNotificationRemovedEvent = Event(
channel: EventChannel(cid: channels.first.cid),
);
eventController.add(channelDeletedOrNotificationRemovedEvent);
final channelCid = channelDeletedOrNotificationRemovedEvent.channel.cid;
final newChannels = [...channels]
..removeWhere((it) => it.cid == channelCid);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(
EventType.channelDeleted,
EventType.notificationRemovedFromChannel,
)).called(1);
},
);
testWidgets(
'event channel should be moved to top of the list if present when'
'EventType.messageNew event is received',
(tester) async {
final mockClient = MockClient();
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.messageNew,
)).thenAnswer((_) => eventController.stream);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
final channels = _generateChannels(mockClient);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final messageNewEvent = Event(
type: EventType.messageNew,
cid: channels.last.cid,
);
eventController.add(messageNewEvent);
final channelCid = messageNewEvent.cid;
final index = channels.indexWhere((it) => it.cid == channelCid);
final updatedChannel = channels[index];
final newChannels = [...channels]
..removeAt(index)
..insert(0, updatedChannel);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(EventType.messageNew)).called(1);
},
);
testWidgets(
'event channel should be moved to top of the list if present inside '
'hiddenChannels list and shouldAddChannel is true when '
'EventType.messageNew event is received',
(tester) async {
final hiddenChannelEventController = StreamController<Event>();
addTearDown(() {
hiddenChannelEventController.close();
});
final mockClient = MockClient();
final channels = _generateChannels(mockClient);
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
shouldAddChannel: (e) => channels.map((it) => it.cid).contains(e.cid),
);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.channelHidden,
)).thenAnswer((_) => hiddenChannelEventController.stream);
when(mockClient.on(
EventType.messageNew,
)).thenAnswer((_) => eventController.stream);
final messageNewEvent = Event(
type: EventType.messageNew,
cid: channels.last.cid,
);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final channelHiddenEvent = Event(
type: EventType.channelHidden,
cid: channels.last.cid,
);
// Hiding the channel before passing messageNew event
hiddenChannelEventController.add(channelHiddenEvent);
final channelsAfterHiddenEvent = [...channels]..removeLast();
eventController.add(messageNewEvent);
final channelCid = messageNewEvent.cid;
final index = channels.indexWhere((it) => it.cid == channelCid);
final newChannels = [...channels]
..removeAt(index)
..insert(0, channels[index]);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(channelsAfterHiddenEvent),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(EventType.channelHidden)).called(1);
verify(mockClient.on(EventType.messageNew)).called(1);
},
);
testWidgets(
'event channel should be moved to top of the list if present inside '
'channel state and shouldAddChannel is true when '
'EventType.messageNew event is received',
(tester) async {
final mockClient = MockClient();
final channels = _generateChannels(mockClient);
final stateChannels = {
for (var c in _generateChannels(mockClient, offset: 5)) c.cid: c
};
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
shouldAddChannel: (_) => true,
);
when(mockClient.state.channels).thenReturn(stateChannels);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.messageNew,
)).thenAnswer((_) => eventController.stream);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final messageNewEvent = Event(
type: EventType.messageNew,
cid: stateChannels.keys.first,
);
eventController.add(messageNewEvent);
final newChannels = [...channels]
..insert(0, stateChannels[stateChannels.keys.first]);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(EventType.messageNew)).called(1);
},
);
testWidgets(
'channels should get sorted according to channelsComparator when '
'EventType.messageNew event is received',
(tester) async {
final mockClient = MockClient();
final channels = _generateChannels(mockClient);
int channelComparator(Channel a, Channel b) {
final aData = a.extraData['extra_data_key'] as String;
final bData = b.extraData['extra_data_key'] as String;
return bData.compareTo(aData);
}
const channelsBlocKey = Key('channelsBloc');
final channelsBloc = ChannelsBloc(
key: channelsBlocKey,
child: Offstage(),
shouldAddChannel: (_) => true,
channelsComparator: channelComparator,
);
when(mockClient.on(any, any, any, any))
.thenAnswer((_) => Stream.empty());
when(mockClient.on(
EventType.messageNew,
)).thenAnswer((_) => eventController.stream);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: channelsBloc,
),
);
final channelsBlocState = tester.state<ChannelsBlocState>(
find.byKey(channelsBlocKey),
);
when(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) => Stream.value(channels),
);
await channelsBlocState.queryChannels();
verify(mockClient.queryChannels(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final messageNewEvent = Event(
type: EventType.messageNew,
cid: channels.first.cid,
);
eventController.add(messageNewEvent);
final newChannels = [...channels]..sort(channelComparator);
await expectLater(
channelsBlocState.channelsStream,
emitsInOrder([
isSameChannelListAs(channels),
isSameChannelListAs(newChannels),
]),
);
verify(mockClient.on(EventType.messageNew)).called(1);
},
);
tearDown(() {
eventController.close();
});
});
}
@@ -0,0 +1,294 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/src/lazy_load_scroll_view.dart';
void main() {
test(
'should throw assertion error if child is null',
() async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
final lazyLoadScrollView = () => LazyLoadScrollView(
key: lazyLoadScrollViewKey,
child: null,
);
expect(lazyLoadScrollView, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should render LazyLoadScrollView if child is provided',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
child: Offstage(),
);
await tester.pumpWidget(lazyLoadScrollView);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
},
);
testWidgets(
'should render LazyLoadScrollView if child is provided',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childKey = Key('childKey');
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
child: Offstage(key: childKey),
);
await tester.pumpWidget(lazyLoadScrollView);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
},
);
testWidgets(
'should call onPageScrollStart if child scrollView scrolls',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childListViewKey = Key('childListView');
bool onPageScrollStartCalled = false;
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
onPageScrollStart: () {
onPageScrollStartCalled = true;
},
child: ListView(
key: childListViewKey,
children: List.generate(
12,
(index) => Container(
height: 100,
child: Text('Item #$index'),
),
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: lazyLoadScrollView,
),
);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childListViewKey), findsOneWidget);
expect(onPageScrollStartCalled, isFalse);
await tester.startGesture(const Offset(100.0, 100.0));
await tester.pump(const Duration(seconds: 1));
expect(onPageScrollStartCalled, isTrue);
},
);
testWidgets(
'should call onPageScrollStart, onPageScrollEnd '
'if child scrollView starts scrolling and ends',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childListViewKey = Key('childListView');
bool onPageScrollStartCalled = false;
bool onPageScrollEndCalled = false;
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
onPageScrollStart: () {
onPageScrollStartCalled = true;
},
onPageScrollEnd: () {
onPageScrollEndCalled = true;
},
child: ListView(
key: childListViewKey,
children: List.generate(
12,
(index) => Container(
height: 100,
child: Text('Item #$index'),
),
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: lazyLoadScrollView,
),
);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childListViewKey), findsOneWidget);
expect(onPageScrollStartCalled, isFalse);
expect(onPageScrollEndCalled, isFalse);
final gesture = await tester.createGesture();
await gesture.down(const Offset(100.0, 100.0));
await tester.pump(const Duration(seconds: 1));
expect(onPageScrollStartCalled, isTrue);
await gesture.up();
await tester.pump(const Duration(seconds: 1));
expect(onPageScrollEndCalled, isTrue);
},
);
testWidgets(
'should call onInBetweenOfPage if child scrollView '
'scroll position is in-between of view',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childListViewKey = Key('childListView');
bool onInBetweenOfPageCalled = false;
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
onInBetweenOfPage: () async {
onInBetweenOfPageCalled = true;
},
child: ListView(
key: childListViewKey,
children: List.generate(
12,
(index) => Container(
height: 100,
child: Text('Item #$index'),
),
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: lazyLoadScrollView,
),
);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childListViewKey), findsOneWidget);
expect(onInBetweenOfPageCalled, isFalse);
final gesture = await tester.createGesture();
await gesture.down(const Offset(100.0, 100.0));
await tester.pump(const Duration(seconds: 1));
await gesture.moveBy(const Offset(-200.0, -200.0));
await tester.pump(const Duration(seconds: 1));
expect(onInBetweenOfPageCalled, isTrue);
},
);
testWidgets(
'should call onStartOfPage if child scrollView '
'tends to reach the start',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childListViewKey = Key('childListView');
bool onStartOfPageCalled = false;
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
onStartOfPage: () async {
onStartOfPageCalled = true;
},
child: ListView(
key: childListViewKey,
children: List.generate(
12,
(index) => Container(
height: 100,
child: Text('Item #$index'),
),
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: lazyLoadScrollView,
),
);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childListViewKey), findsOneWidget);
expect(onStartOfPageCalled, isFalse);
final gesture = await tester.createGesture();
await gesture.down(const Offset(100.0, 100.0));
await tester.pump(const Duration(seconds: 1));
await gesture.moveBy(const Offset(-200.0, -200.0));
await tester.pump(const Duration(seconds: 1));
await gesture.moveBy(const Offset(201.0, 201.0));
await tester.pump(const Duration(seconds: 1));
expect(onStartOfPageCalled, isTrue);
},
);
testWidgets(
'should call onEndOfPage if child scrollView '
'tends to reach the end',
(tester) async {
const lazyLoadScrollViewKey = Key('lazyLoadScrollView');
const childListViewKey = Key('childListView');
bool onEndOfPageCalled = false;
final lazyLoadScrollView = LazyLoadScrollView(
key: lazyLoadScrollViewKey,
onEndOfPage: () async {
onEndOfPageCalled = true;
},
child: ListView(
key: childListViewKey,
children: List.generate(
12,
(index) => Container(
height: 100,
child: Text('Item #$index'),
),
),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: lazyLoadScrollView,
),
);
expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget);
expect(find.byKey(childListViewKey), findsOneWidget);
expect(onEndOfPageCalled, isFalse);
final gesture = await tester.createGesture();
await gesture.down(const Offset(100.0, 100.0));
await tester.pump(const Duration(seconds: 1));
await gesture.moveBy(const Offset(-601.0, -601.0));
await tester.pump(const Duration(seconds: 1));
expect(onEndOfPageCalled, isTrue);
},
);
}
@@ -0,0 +1,49 @@
import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Matcher isSameChannelAs(Channel targetChannel) =>
_IsSameChannelAs(targetChannel: targetChannel);
class _IsSameChannelAs extends Matcher {
const _IsSameChannelAs({
@required this.targetChannel,
}) : assert(targetChannel != null, '');
final Channel targetChannel;
@override
bool matches(covariant Channel channel, Map matchState) =>
channel.cid == targetChannel.cid;
@override
Description describe(Description description) =>
description.add('is same channel as $targetChannel');
}
Matcher isSameChannelListAs(List<Channel> targetChannelList) =>
_IsSameChannelListAs(targetChannelList: targetChannelList);
class _IsSameChannelListAs extends Matcher {
const _IsSameChannelListAs({
@required this.targetChannelList,
}) : assert(targetChannelList != null, '');
final List<Channel> targetChannelList;
@override
bool matches(covariant List<Channel> channelList, Map matchState) {
bool matches = true;
for (var i = 0; i < channelList.length; i++) {
final channel = channelList[i];
final targetChannel = targetChannelList[i];
matches = isSameChannelAs(targetChannel).matches(channel, matchState);
if (!matches) break;
}
return matches;
}
@override
Description describe(Description description) =>
description.add('is same channelList as $targetChannelList');
}
@@ -0,0 +1,55 @@
import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Matcher isSameMessageResponseAs(GetMessageResponse targetResponse) =>
_IsSameMessageResponseAs(targetResponse: targetResponse);
class _IsSameMessageResponseAs extends Matcher {
const _IsSameMessageResponseAs({
@required this.targetResponse,
}) : assert(targetResponse != null, '');
final GetMessageResponse targetResponse;
@override
bool matches(covariant GetMessageResponse response, Map matchState) =>
response.message.id == targetResponse.message.id &&
response.channel.cid == targetResponse.channel.cid;
@override
Description describe(Description description) =>
description.add('is same message response as $targetResponse');
}
Matcher isSameMessageResponseListAs(
List<GetMessageResponse> targetResponseList) =>
_IsSameMessageResponseListAs(targetResponseList: targetResponseList);
class _IsSameMessageResponseListAs extends Matcher {
const _IsSameMessageResponseListAs({
@required this.targetResponseList,
}) : assert(targetResponseList != null, '');
final List<GetMessageResponse> targetResponseList;
@override
bool matches(
covariant List<GetMessageResponse> responseList, Map matchState) {
bool matches = true;
for (var i = 0; i < responseList.length; i++) {
final response = responseList[i];
final targetResponse = targetResponseList[i];
matches = isSameMessageResponseAs(targetResponse).matches(
response,
matchState,
);
if (!matches) break;
}
return matches;
}
@override
Description describe(Description description) =>
description.add('is same userResponseList as $targetResponseList');
}
@@ -0,0 +1,49 @@
import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Matcher isSameMessageAs(Message targetMessage) =>
_IsSameMessageAs(targetMessage: targetMessage);
class _IsSameMessageAs extends Matcher {
const _IsSameMessageAs({
@required this.targetMessage,
}) : assert(targetMessage != null, '');
final Message targetMessage;
@override
bool matches(covariant Message message, Map matchState) =>
message.id == targetMessage.id;
@override
Description describe(Description description) =>
description.add('is same message as $targetMessage');
}
Matcher isSameMessageListAs(List<Message> targetMessageList) =>
_IsSameMessageListAs(targetMessageList: targetMessageList);
class _IsSameMessageListAs extends Matcher {
const _IsSameMessageListAs({
@required this.targetMessageList,
}) : assert(targetMessageList != null, '');
final List<Message> targetMessageList;
@override
bool matches(covariant List<Message> messageList, Map matchState) {
bool matches = true;
for (var i = 0; i < messageList.length; i++) {
final message = messageList[i];
final targetMessage = targetMessageList[i];
matches = isSameMessageAs(targetMessage).matches(message, matchState);
if (!matches) break;
}
return matches;
}
@override
Description describe(Description description) =>
description.add('is same messageList as $targetMessageList');
}
@@ -0,0 +1,47 @@
import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser);
class _IsSameUserAs extends Matcher {
const _IsSameUserAs({
@required this.targetUser,
}) : assert(targetUser != null, '');
final User targetUser;
@override
bool matches(covariant User user, Map matchState) => user.id == targetUser.id;
@override
Description describe(Description description) =>
description.add('is same user as $targetUser');
}
Matcher isSameUserListAs(List<User> targetUserList) =>
_IsSameUserListAs(targetUserList: targetUserList);
class _IsSameUserListAs extends Matcher {
const _IsSameUserListAs({
@required this.targetUserList,
}) : assert(targetUserList != null, '');
final List<User> targetUserList;
@override
bool matches(covariant List<User> userList, Map matchState) {
bool matches = true;
for (var i = 0; i < userList.length; i++) {
final user = userList[i];
final targetUser = targetUserList[i];
matches = isSameUserAs(targetUser).matches(user, matchState);
if (!matches) break;
}
return matches;
}
@override
Description describe(Description description) =>
description.add('is same userList as $targetUserList');
}
@@ -0,0 +1,413 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/src/message_list_core.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart';
void main() {
List<Message> _generateMessages({
int count = 3,
int offset = 0,
bool threads = false,
}) {
final users = List.generate(count, (index) {
index = count + offset;
return User(id: 'testUserId$index');
});
final messages = List.generate(
count,
(index) {
index = index + offset;
return Message(
id: 'testMessageId$index',
type: 'testType',
user: users[index],
createdAt: DateTime.now(),
shadowed: false,
replyCount: index,
updatedAt: DateTime.now(),
extraData: {'extra_test_field': 'extraTestData'},
text: 'Dummy text #$index',
pinned: true,
pinnedAt: DateTime.now(),
pinnedBy: User(id: 'testUserId$index'),
);
},
);
final threadMessages = List.generate(
count,
(index) {
index = index + offset;
return Message(
id: 'testThreadMessageId$index',
type: 'testType',
user: users[index],
parentId: messages[0].id,
createdAt: DateTime.now(),
shadowed: false,
replyCount: index,
updatedAt: DateTime.now(),
extraData: {'extra_test_field': 'extraTestData'},
text: 'Dummy text #$index',
pinned: true,
pinnedAt: DateTime.now(),
pinnedBy: User(id: 'testUserId$index'),
);
},
);
return threads ? threadMessages : messages;
}
test(
'should throw assertion error in case messageListBuilder is null',
() {
final messageListCore = () => MessageListCore(
messageListBuilder: null,
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) =>
Offstage(),
);
expect(messageListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case loadingBuilder is null',
() {
final messageListCore = () => MessageListCore(
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: null,
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) =>
Offstage(),
);
expect(messageListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case emptyBuilder is null',
() {
final messageListCore = () => MessageListCore(
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: null,
errorWidgetBuilder: (BuildContext context, Object error) =>
Offstage(),
);
expect(messageListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case errorWidgetBuilder is null',
() {
final messageListCore = () => MessageListCore(
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: null,
);
expect(messageListCore, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should throw if MessageListCore is used where StreamChannel is not present '
'in the widget tree',
(tester) async {
const messageListCoreKey = Key('messageListCore');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
);
await tester.pumpWidget(messageListCore);
expect(find.byKey(messageListCoreKey), findsNothing);
expect(tester.takeException(), isInstanceOf<Exception>());
},
);
testWidgets(
'should render MessageListCore if used with StreamChannel as an ancestor',
(tester) async {
const messageListCoreKey = Key('messageListCore');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
await tester.pumpWidget(
StreamChannel(
channel: mockChannel,
child: messageListCore,
),
);
expect(find.byKey(messageListCoreKey), findsOneWidget);
},
);
testWidgets(
'should assign paginateData callback to MessageListController if passed',
(tester) async {
const messageListCoreKey = Key('messageListCore');
final controller = MessageListController();
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
messageListController: controller,
);
expect(controller.paginateData, isNull);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
await tester.pumpWidget(
StreamChannel(
channel: mockChannel,
child: messageListCore,
),
);
expect(find.byKey(messageListCoreKey), findsOneWidget);
expect(controller.paginateData, isNotNull);
},
);
testWidgets(
'should build error widget if messagesStream emits error',
(tester) async {
const messageListCoreKey = Key('messageListCore');
const errorWidgetKey = Key('errorWidget');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(
key: errorWidgetKey,
),
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
when(mockChannel.initialized).thenAnswer((_) async => true);
const error = 'Error! Error! Error!';
when(mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.error(error));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChannel(
channel: mockChannel,
child: messageListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(errorWidgetKey), findsOneWidget);
},
);
testWidgets(
'should build empty widget if the channel is upToDate and '
'messagesStream emits empty data',
(tester) async {
const messageListCoreKey = Key('messageListCore');
const emptyWidgetKey = Key('emptyWidget');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
when(mockChannel.initialized).thenAnswer((_) async => true);
const messages = <Message>[];
when(mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChannel(
channel: mockChannel,
child: messageListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(emptyWidgetKey), findsOneWidget);
},
);
testWidgets(
'should build list widget if the channel is not upToDate and '
'messagesStream emits empty data',
(tester) async {
const messageListCoreKey = Key('messageListCore');
const listWidgetKey = Key('listWidget');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, __) => Offstage(key: listWidgetKey),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(false);
when(mockChannel.initialized).thenAnswer((_) async => true);
const messages = <Message>[];
when(mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChannel(
channel: mockChannel,
child: messageListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
},
);
testWidgets(
'should build list widget and save it for future rebuilds '
'if messagesStream emits some data',
(tester) async {
const messageListCoreKey = Key('messageListCore');
const listWidgetKey = Key('listWidget');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, messages) => Container(
key: listWidgetKey,
child: Text(
messages.reversed.map((it) => it.id).join(','),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
when(mockChannel.initialized).thenAnswer((_) async => true);
final messages = _generateMessages();
when(mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChannel(
channel: mockChannel,
child: messageListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(find.text(messages.map((it) => it.id).join(',')), findsOneWidget);
},
);
testWidgets(
'should build list widget with thread messages if parentMessage is passed',
(tester) async {
final messages = _generateMessages(threads: true);
final parentMessage = Message(id: messages.first.parentId);
const messageListCoreKey = Key('messageListCore');
const listWidgetKey = Key('listWidget');
final messageListCore = MessageListCore(
key: messageListCoreKey,
messageListBuilder: (_, messages) => Container(
key: listWidgetKey,
child: Text(
messages.reversed.map((it) => '${it.parentId}-${it.id}').join(','),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorWidgetBuilder: (BuildContext context, Object error) => Offstage(),
parentMessage: parentMessage,
);
final mockChannel = MockChannel();
when(mockChannel.state.isUpToDate).thenReturn(true);
when(mockChannel.initialized).thenAnswer((_) async => true);
final threads = {parentMessage.id: messages};
when(mockChannel.state.threads).thenReturn(threads);
when(mockChannel.state.threadsStream)
.thenAnswer((_) => Stream.value(threads));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChannel(
channel: mockChannel,
child: messageListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
expect(
find.text(messages.map((it) => '${it.parentId}-${it.id}').join(',')),
findsOneWidget,
);
},
);
}
@@ -0,0 +1,344 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/message_search_bloc.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'matchers/get_message_response_matcher.dart';
import 'mocks.dart';
void main() {
List<GetMessageResponse> _generateMessages({
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return GetMessageResponse()
..message = Message(
id: 'testId$index',
text: 'testTextData$index',
)
..channel = ChannelModel(
cid: 'testCid',
);
},
);
}
test(
'should throw assertion error if child is null',
() async {
const messageSearchBlocKey = Key('messageSearchBloc');
final messageSearchBloc = () => MessageSearchBloc(
key: messageSearchBlocKey,
child: null,
);
expect(messageSearchBloc, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'messageSearchBlocState.search() should throw if used where '
'StreamChat is not present in the widget tree',
(tester) async {
const messageSearchBlocKey = Key('messageSearchBloc');
const childKey = Key('child');
final messageSearchBloc = MessageSearchBloc(
key: messageSearchBlocKey,
child: Offstage(key: childKey),
);
await tester.pumpWidget(messageSearchBloc);
expect(find.byKey(messageSearchBlocKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final usersBlocState = tester.state<MessageSearchBlocState>(
find.byKey(messageSearchBlocKey),
);
try {
await usersBlocState.search();
} catch (e) {
expect(e, isInstanceOf<Exception>());
}
},
);
testWidgets(
'messageSearchBlocState.search() should emit data through usersStream',
(tester) async {
const messageSearchBlocKey = Key('messageSearchBloc');
const childKey = Key('child');
final messageSearchBloc = MessageSearchBloc(
key: messageSearchBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: messageSearchBloc,
),
);
final messageSearchBlocState = tester.state<MessageSearchBlocState>(
find.byKey(messageSearchBlocKey),
);
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
messageSearchBlocState.search();
await expectLater(
messageSearchBlocState.messagesStream,
emits(isSameMessageResponseListAs(messageResponseList)),
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'messageSearchBlocState.messagesStream should emit error '
'if client.search() throws',
(tester) async {
const messageSearchBlocKey = Key('messageSearchBloc');
const childKey = Key('child');
final messageSearchBloc = MessageSearchBloc(
key: messageSearchBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: messageSearchBloc,
),
);
final messageSearchBlocState = tester.state<MessageSearchBlocState>(
find.byKey(messageSearchBlocKey),
);
const error = 'Error! Error! Error!';
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenThrow(error);
messageSearchBlocState.search();
await expectLater(
messageSearchBlocState.messagesStream,
emitsError(error),
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'calling messageSearchBlocState.search() again with an offset '
'should emit new data through messagesStream and also emit loading state '
'through queryMessagesLoading',
(tester) async {
const messageSearchBlocKey = Key('messageSearchBloc');
const childKey = Key('child');
final messageSearchBloc = MessageSearchBloc(
key: messageSearchBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: messageSearchBloc,
),
);
final messageSearchBlocState = tester.state<MessageSearchBlocState>(
find.byKey(messageSearchBlocKey),
);
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
messageSearchBlocState.search();
await expectLater(
messageSearchBlocState.messagesStream,
emits(isSameMessageResponseListAs(messageResponseList)),
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final offset = messageResponseList.length;
final paginatedMessageResponseList = _generateMessages(offset: offset);
final pagination = PaginationParams(offset: offset);
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).thenAnswer(
(_) async =>
SearchMessagesResponse()..results = paginatedMessageResponseList,
);
messageSearchBlocState.search(pagination: pagination);
await Future.wait([
expectLater(
messageSearchBlocState.queryMessagesLoading,
emitsInOrder([true, false]),
),
expectLater(
messageSearchBlocState.messagesStream,
emits(isSameMessageResponseListAs(
messageResponseList + paginatedMessageResponseList,
)),
),
]);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).called(1);
},
);
testWidgets(
'calling messageSearchBlocState.search() again with an offset '
'should emit error through queryUsersLoading if '
'client.search() throws',
(tester) async {
const messageSearchBlocKey = Key('messageSearchBloc');
const childKey = Key('child');
final messageSearchBloc = MessageSearchBloc(
key: messageSearchBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: messageSearchBloc,
),
);
final messageSearchBlocState = tester.state<MessageSearchBlocState>(
find.byKey(messageSearchBlocKey),
);
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
messageSearchBlocState.search();
await expectLater(
messageSearchBlocState.messagesStream,
emits(isSameMessageResponseListAs(messageResponseList)),
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
final offset = messageResponseList.length;
final pagination = PaginationParams(offset: offset);
const error = 'Error! Error! Error!';
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).thenThrow(error);
messageSearchBlocState.search(pagination: pagination);
await expectLater(
messageSearchBlocState.queryMessagesLoading,
emitsError(error),
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).called(1);
},
);
}
@@ -0,0 +1,535 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter_core/src/message_search_list_core.dart';
import 'mocks.dart';
void main() {
List<GetMessageResponse> _generateMessages({
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return GetMessageResponse()
..message = Message(
id: 'testId$index',
text: 'testTextData$index',
)
..channel = ChannelModel(
cid: 'testCid',
);
},
);
}
test(
'should throw assertion error in case childBuilder is null',
() {
final messageSearchListCore = () => MessageSearchListCore(
childBuilder: null,
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(messageSearchListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case loadingBuilder is null',
() {
final messageSearchListCore = () => MessageSearchListCore(
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: null,
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(messageSearchListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case emptyBuilder is null',
() {
final messageSearchListCore = () => MessageSearchListCore(
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: null,
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
expect(messageSearchListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case errorBuilder is null',
() {
final messageSearchListCore = () => MessageSearchListCore(
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: null,
);
expect(messageSearchListCore, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should throw if MessageSearchListCore is used where MessageSearchBloc '
'is not present in the widget tree',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
await tester.pumpWidget(messageSearchListCore);
expect(find.byKey(messageSearchListCoreKey), findsNothing);
expect(tester.takeException(), isInstanceOf<Exception>());
},
);
testWidgets(
'should render MessageSearchListCore if used with '
'MessageSearchListCore as an ancestor',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
);
expect(find.byKey(messageSearchListCoreKey), findsOneWidget);
},
);
testWidgets(
'should assign loadData and paginateData callback to '
'UserListController if passed',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
final controller = MessageSearchListController();
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
messageSearchListController: controller,
);
expect(controller.loadData, isNull);
expect(controller.paginateData, isNull);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
);
expect(find.byKey(messageSearchListCoreKey), findsOneWidget);
expect(controller.loadData, isNotNull);
expect(controller.paginateData, isNotNull);
},
);
testWidgets(
'should build error widget if messageSearchBloc.messagesStream emits error',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
const errorWidgetKey = Key('errorWidget');
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(
key: errorWidgetKey,
),
);
final mockClient = MockClient();
const error = 'Error! Error! Error!';
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenThrow(error);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(errorWidgetKey), findsOneWidget);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'should build empty widget if messageSearchBloc.messagesStream '
'emits empty data',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
const emptyWidgetKey = Key('emptyWidget');
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockClient = MockClient();
final messageResponseList = <GetMessageResponse>[];
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(emptyWidgetKey), findsOneWidget);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'should build child widget if usersBlocState.usersStream emits some data',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
const childWidgetKey = Key('childWidget');
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Offstage(
key: childWidgetKey,
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
);
final mockClient = MockClient();
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(childWidgetKey), findsOneWidget);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: anyNamed('paginationParams'),
)).called(1);
},
);
testWidgets(
'should build child widget with paginated data '
'on calling channelListCoreState.paginateData',
(tester) async {
const messageSearchListCoreKey = Key('messageSearchListCore');
const childWidgetKey = Key('childWidget');
const pagination = PaginationParams();
final messageSearchListCore = MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Container(
key: childWidgetKey,
child: Text(
messages.map((e) => '${e.channel.cid}-${e.message.id}').join(','),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
paginationParams: pagination,
);
final mockClient = MockClient();
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: messageSearchListCore,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(childWidgetKey), findsOneWidget);
expect(
find.text(
messageResponseList
.map((e) => '${e.channel.cid}-${e.message.id}')
.join(','),
),
findsOneWidget,
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).called(1);
final messageSearchListCoreState =
tester.state<MessageSearchListCoreState>(
find.byKey(messageSearchListCoreKey),
);
final offset = messageResponseList.length;
final paginatedMessageResponseList = _generateMessages(offset: offset);
final updatedPagination = pagination.copyWith(offset: offset);
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: updatedPagination,
)).thenAnswer(
(_) async =>
SearchMessagesResponse()..results = paginatedMessageResponseList,
);
await messageSearchListCoreState.paginateData();
await tester.pumpAndSettle();
expect(find.byKey(childWidgetKey), findsOneWidget);
expect(
find.text([
...messageResponseList,
...paginatedMessageResponseList,
].map((e) => '${e.channel.cid}-${e.message.id}').join(',')),
findsOneWidget,
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: updatedPagination,
)).called(1);
},
);
testWidgets(
'should rebuild MessageSearchListCore with updated widget data '
'on calling setState()',
(tester) async {
const pagination = PaginationParams();
StateSetter _stateSetter;
int limit = pagination.limit;
const messageSearchListCoreKey = Key('messageSearchListCore');
const childWidgetKey = Key('childWidget');
MessageSearchListCore messageSearchListCoreBuilder(int limit) =>
MessageSearchListCore(
key: messageSearchListCoreKey,
childBuilder: (List<GetMessageResponse> messages) => Container(
key: childWidgetKey,
child: Text(
messages
.map((e) => '${e.channel.cid}-${e.message.id}')
.join(','),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (BuildContext context, Object error) => Offstage(),
paginationParams: pagination.copyWith(limit: limit),
);
final mockClient = MockClient();
final messageResponseList = _generateMessages();
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).thenAnswer(
(_) async => SearchMessagesResponse()..results = messageResponseList,
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: MessageSearchBloc(
child: StatefulBuilder(builder: (context, stateSetter) {
// Assigning stateSetter for rebuilding UserListCore
_stateSetter = stateSetter;
return messageSearchListCoreBuilder(limit);
}),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(childWidgetKey), findsOneWidget);
expect(
find.text(
messageResponseList
.map((e) => '${e.channel.cid}-${e.message.id}')
.join(','),
),
findsOneWidget,
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: pagination,
)).called(1);
// Rebuilding MessageSearchListCore with new pagination limit
_stateSetter(() => limit = 6);
final updatedMessageResponseList = _generateMessages(count: limit);
final updatedPagination = pagination.copyWith(limit: limit);
when(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: updatedPagination,
)).thenAnswer(
(_) async =>
SearchMessagesResponse()..results = updatedMessageResponseList,
);
await tester.pumpAndSettle();
expect(find.byKey(childWidgetKey), findsOneWidget);
expect(
find.text(updatedMessageResponseList
.map((e) => '${e.channel.cid}-${e.message.id}')
.join(',')),
findsOneWidget,
);
verify(mockClient.search(
any,
query: anyNamed('query'),
sort: anyNamed('sort'),
messageFilters: anyNamed('messageFilters'),
paginationParams: updatedPagination,
)).called(1);
},
);
}
@@ -1,10 +1,39 @@
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
class MockClient extends Mock implements StreamChatClient {} class MockLogger extends Mock implements Logger {}
class MockClientState extends Mock implements ClientState {} class MockClient extends Mock implements StreamChatClient {
final Logger logger = MockLogger();
class MockChannel extends Mock implements Channel {} ClientState _state;
@override
ClientState get state => _state ??= MockClientState();
}
class MockClientState extends Mock implements ClientState {
OwnUser _user;
@override
OwnUser get user => _user ??= OwnUser(
id: 'testUserId',
role: 'admin',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
class MockChannel extends Mock implements Channel {
ChannelClientState _state;
@override
ChannelClientState get state => _state ??= MockChannelState();
StreamChatClient _client;
@override
StreamChatClient get client => _client ??= MockClient();
}
class MockChannelState extends Mock implements ChannelClientState {} class MockChannelState extends Mock implements ChannelClientState {}
@@ -0,0 +1,343 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart';
void main() {
List<Message> _generateMessages({
int count = 3,
int offset = 0,
bool threads = false,
}) {
final users = List.generate(count, (index) {
index = count + offset;
return User(id: 'testUserId$index');
});
final messages = List.generate(
count,
(index) {
index = index + offset;
return Message(
id: 'testMessageId$index',
type: 'testType',
user: users[index],
createdAt: DateTime.now(),
shadowed: false,
replyCount: index,
updatedAt: DateTime.now(),
extraData: {'extra_test_field': 'extraTestData'},
text: 'Dummy text #$index',
pinned: true,
pinnedAt: DateTime.now(),
pinnedBy: User(id: 'testUserId$index'),
);
},
);
final threadMessages = List.generate(
count,
(index) {
index = index + offset;
return Message(
id: 'testThreadMessageId$index',
type: 'testType',
user: users[index],
parentId: messages[0].id,
createdAt: DateTime.now(),
shadowed: false,
replyCount: index,
updatedAt: DateTime.now(),
extraData: {'extra_test_field': 'extraTestData'},
text: 'Dummy text #$index',
pinned: true,
pinnedAt: DateTime.now(),
pinnedBy: User(id: 'testUserId$index'),
);
},
);
return threads ? threadMessages : messages;
}
test(
'should throw assertion error if child is null',
() async {
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
final streamChannel = () => StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: null,
);
expect(streamChannel, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error if channel is null',
() async {
const streamChannelKey = Key('streamChannel');
final streamChannel = () => StreamChannel(
key: streamChannelKey,
child: Offstage(),
channel: null,
);
expect(streamChannel, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should render StreamChannel if both channel and child is provided',
(tester) async {
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
const childKey = Key('childKey');
final streamChannel = StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: Offstage(key: childKey),
);
await tester.pumpWidget(streamChannel);
expect(find.byKey(streamChannelKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
},
);
testWidgets(
'should build error widget if channel.initialized throws',
(tester) async {
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
const childKey = Key('childKey');
final streamChannel = StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: Offstage(key: childKey),
);
final errorMessage = 'Error! Error! Error!';
final error = DioError(type: DioErrorType.RESPONSE, error: errorMessage);
when(mockChannel.initialized).thenAnswer((_) => Future.error(error));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: streamChannel,
),
);
await tester.pumpAndSettle();
expect(find.text(errorMessage), findsOneWidget);
verify(mockChannel.initialized).called(1);
},
);
testWidgets(
'should show circular progress indicator showLoading is true '
'and channel is still not initialized',
(tester) async {
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
const childKey = Key('childKey');
final streamChannel = StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: Offstage(key: childKey),
showLoading: true,
);
when(mockChannel.initialized).thenAnswer((_) async => false);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: streamChannel,
),
);
await tester.pump(const Duration(seconds: 1));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
verify(mockChannel.initialized).called(1);
},
);
testWidgets(
'should preload extra messages if initialMessageId is provided',
(tester) async {
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
const childKey = Key('childKey');
final streamChannel = StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: Offstage(key: childKey),
initialMessageId: 'testInitialMessageId',
);
when(mockChannel.initialized).thenAnswer((_) async => true);
final messages = _generateMessages();
when(mockChannel.query(
options: anyNamed('options'),
messagesPagination: anyNamed('messagesPagination'),
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: streamChannel,
),
);
await tester.pumpAndSettle();
verify(mockChannel.initialized).called(1);
verify(mockChannel.query(
options: anyNamed('options'),
messagesPagination: anyNamed('messagesPagination'),
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).called(
2, // Fetching After messages + Fetching Before messages,
);
},
);
testWidgets(
'should rebuild StreamChannel with updated widget data '
'on calling setState()',
(tester) async {
StateSetter _stateSetter;
var initialMessageId = 'testInitialMessageId';
final mockChannel = MockChannel();
const streamChannelKey = Key('streamChannel');
const childKey = Key('childKey');
StreamChannel streamChannelBuilder(String initialMessageId) =>
StreamChannel(
key: streamChannelKey,
channel: mockChannel,
child: Offstage(key: childKey),
initialMessageId: initialMessageId,
);
final beforePagination = PaginationParams(
lessThan: initialMessageId,
limit: 20,
);
final afterPagination = PaginationParams(
greaterThanOrEqual: initialMessageId,
limit: 20,
);
when(mockChannel.initialized).thenAnswer((_) async => true);
final messages = _generateMessages();
when(mockChannel.query(
options: anyNamed('options'),
messagesPagination: beforePagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
when(mockChannel.query(
options: anyNamed('options'),
messagesPagination: afterPagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (context, stateSetter) {
// Assigning stateSetter for rebuilding StreamChannel
_stateSetter = stateSetter;
return streamChannelBuilder(initialMessageId);
},
),
),
);
await tester.pumpAndSettle();
verify(mockChannel.query(
options: anyNamed('options'),
messagesPagination: beforePagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).called(1);
verify(mockChannel.query(
options: anyNamed('options'),
messagesPagination: afterPagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).called(1);
_stateSetter(() => initialMessageId = 'testInitialMessageId2');
final updatedBeforePagination = beforePagination.copyWith(
lessThan: initialMessageId,
);
final updatedAfterPagination = afterPagination.copyWith(
greaterThanOrEqual: initialMessageId,
);
when(mockChannel.query(
options: anyNamed('options'),
messagesPagination: updatedBeforePagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
when(mockChannel.query(
options: anyNamed('options'),
messagesPagination: updatedAfterPagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
await tester.pumpAndSettle();
verify(mockChannel.query(
options: anyNamed('options'),
messagesPagination: updatedBeforePagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).called(1);
verify(mockChannel.query(
options: anyNamed('options'),
messagesPagination: updatedAfterPagination,
membersPagination: anyNamed('membersPagination'),
watchersPagination: anyNamed('watchersPagination'),
preferOffline: anyNamed('preferOffline'),
)).called(1);
},
);
}
@@ -1,181 +1,289 @@
import 'dart:async'; import 'dart:async';
import 'package:fake_async/fake_async.dart';
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:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart'; import 'mocks.dart';
class MockShowLocalNotifications extends Mock { class MockOnBackgroundEventReceived extends Mock {
void call(Event event); void call(Event event);
} }
void main() { void main() {
testWidgets( test(
'StreamChatCore.of(context) should throw an exception', 'should throw assertion error in case client is null',
(WidgetTester tester) async { () {
await tester.pumpWidget( final streamChatCore = () => StreamChatCore(
Builder( client: null,
builder: (context) { child: Offstage(),
expect(() => StreamChatCore.of(context), throwsException);
return Container();
},
),
); );
expect(streamChatCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case child is null',
() {
final mockClient = MockClient();
final streamChatCore = () => StreamChatCore(
client: mockClient,
child: null,
);
expect(streamChatCore, throwsA(isA<AssertionError>()));
}, },
); );
testWidgets( testWidgets(
'StreamChatCore.of(context) should return the StreamChatCore ancestor', 'should render StreamChatCore if both client and child is provided',
(WidgetTester tester) async { (tester) async {
final client = MockClient(); final mockClient = MockClient();
await tester.pumpWidget( const streamChatCoreKey = Key('streamChatCore');
StreamChatCore( const childKey = Key('child');
client: client, final streamChatCore = StreamChatCore(
child: Builder( key: streamChatCoreKey,
builder: (context) { client: mockClient,
final sc = StreamChatCore.of(context); child: Offstage(key: childKey),
expect(sc, isNotNull);
return Container();
},
),
),
); );
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
}, },
); );
testWidgets( testWidgets(
'StreamChatCore.of(context).client should return the client', 'should render StreamChatCore if both client and child is provided',
(WidgetTester tester) async { (tester) async {
final client = MockClient(); final mockClient = MockClient();
final clientState = MockClientState(); const streamChatCoreKey = Key('streamChatCore');
when(client.state).thenReturn(clientState); const childKey = Key('child');
when(clientState.user).thenReturn( final streamChatCore = StreamChatCore(
OwnUser( key: streamChatCoreKey,
id: 'test', client: mockClient,
), child: Offstage(key: childKey),
);
final userStream = Stream<OwnUser>.value(
OwnUser(
id: 'test',
),
);
when(clientState.userStream).thenAnswer(
(_) => userStream,
); );
await tester.pumpWidget( await tester.pumpWidget(streamChatCore);
StreamChatCore(
client: client, expect(find.byKey(streamChatCoreKey), findsOneWidget);
child: Builder( expect(find.byKey(childKey), findsOneWidget);
builder: (context) {
final sc = StreamChatCore.of(context);
expect(sc.client, client);
expect(sc.user, client.state.user);
expect(sc.userStream, userStream);
return Container();
},
),
),
);
}, },
); );
testWidgets( testWidgets(
'StreamChatCore should disconnect on background', 'didChangeAppLifecycleState should call client.disconnect() and return '
(WidgetTester tester) async { 'if onBackgroundEventReceived is null and the widget lifestyle changes to '
fakeAsync((_async) { 'AppLifecycleState.paused',
final client = MockClient(); (tester) async {
final clientState = MockClientState(); final mockClient = MockClient();
final channel = MockChannel(); const streamChatCoreKey = Key('streamChatCore');
when(client.state).thenReturn(clientState); const childKey = Key('child');
when(clientState.user).thenReturn( final streamChatCore = StreamChatCore(
OwnUser( key: streamChatCoreKey,
id: 'test', client: mockClient,
), child: Offstage(key: childKey),
); );
final showLocalNotificationMock = MockShowLocalNotifications().call;
final eventStreamController = StreamController<Event>();
when(client.on()).thenAnswer((_) => eventStreamController.stream);
when(client.channel('test', id: 'testid')).thenReturn(channel); await tester.pumpWidget(streamChatCore);
final scKey = GlobalKey<StreamChatCoreState>(); expect(find.byKey(streamChatCoreKey), findsOneWidget);
tester.pumpWidget( expect(find.byKey(childKey), findsOneWidget);
StreamChatCore(
key: scKey, when(mockClient.disconnect()).thenAnswer((_) async {
client: client, return;
onBackgroundEventReceived: showLocalNotificationMock, });
backgroundKeepAlive: const Duration(seconds: 4),
child: Builder( final streamChatCoreState = tester.state<StreamChatCoreState>(
builder: (context) { find.byKey(streamChatCoreKey),
return Container(); );
streamChatCoreState.didChangeAppLifecycleState(AppLifecycleState.paused);
verify(mockClient.disconnect()).called(1);
}, },
),
),
); );
final sc = scKey.currentState; testWidgets(
sc.didChangeAppLifecycleState(AppLifecycleState.paused); 'didChangeAppLifecycleState should subscribe to client events for '
'backgroundKeepAlive duration if onBackgroundEventReceived is provided '
'and the widget lifestyle changes to AppLifecycleState.paused',
(tester) async {
await tester.runAsync(() async {
final mockClient = MockClient();
final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived();
const backgroundKeepAlive = const Duration(seconds: 3);
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
onBackgroundEventReceived: mockOnBackgroundEventReceived,
backgroundKeepAlive: backgroundKeepAlive,
);
_async.elapse(Duration(seconds: 5)); await tester.pumpWidget(streamChatCore);
verify(client.disconnect()).called(1); expect(find.byKey(streamChatCoreKey), findsOneWidget);
eventStreamController.close(); expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(mockClient.on()).thenAnswer((_) => Stream.value(event));
when(mockClient.disconnect()).thenAnswer((_) async {
return;
});
final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey),
);
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.paused);
await untilCalled(mockOnBackgroundEventReceived.call(event));
verify(mockOnBackgroundEventReceived.call(event)).called(1);
await Future.delayed(backgroundKeepAlive);
verify(mockClient.disconnect()).called(1);
verifyNever(mockOnBackgroundEventReceived.call(event));
}); });
}, },
); );
testWidgets( testWidgets(
'StreamChatCore should handle notifications when on background and connected', 'didChangeAppLifecycleState should cancel the backgroundKeepAlive timer '
(WidgetTester tester) async { 'if it is currently running in case the widget lifestyle changes to '
final client = MockClient(); 'AppLifecycleState.resume',
final clientState = MockClientState(); (tester) async {
final channel = MockChannel(); await tester.runAsync(() async {
when(client.state).thenReturn(clientState); final mockClient = MockClient();
when(clientState.user).thenReturn( final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived();
OwnUser( const backgroundKeepAlive = const Duration(seconds: 3);
id: 'test', const streamChatCoreKey = Key('streamChatCore');
), const childKey = Key('child');
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
onBackgroundEventReceived: mockOnBackgroundEventReceived,
backgroundKeepAlive: backgroundKeepAlive,
); );
final showLocalNotificationMock = MockShowLocalNotifications().call;
final eventStreamController = StreamController<Event>();
when(client.on()).thenAnswer((_) => eventStreamController.stream);
when(client.channel('test', id: 'testid')).thenReturn(channel); await tester.pumpWidget(streamChatCore);
final scKey = GlobalKey<StreamChatCoreState>(); expect(find.byKey(streamChatCoreKey), findsOneWidget);
await tester.pumpWidget( expect(find.byKey(childKey), findsOneWidget);
StreamChatCore(
key: scKey, final event = Event();
client: client, when(mockClient.on()).thenAnswer((_) => Stream.value(event));
onBackgroundEventReceived: showLocalNotificationMock,
backgroundKeepAlive: const Duration(seconds: 4), final streamChatCoreState = tester.state<StreamChatCoreState>(
child: Builder( find.byKey(streamChatCoreKey),
builder: (context) { );
return Container();
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.paused);
await untilCalled(mockOnBackgroundEventReceived.call(event));
verify(mockOnBackgroundEventReceived.call(event)).called(1);
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.resumed);
verifyNever(mockOnBackgroundEventReceived.call(event));
});
}, },
),
),
); );
final sc = scKey.currentState; testWidgets(
sc.didChangeAppLifecycleState(AppLifecycleState.paused); 'didChangeAppLifecycleState should call client.connect() '
final event = Event( 'if the connectionStatus is ConnectionStatus.disconnected in case the '
type: EventType.messageNew, 'widget lifestyle changes to AppLifecycleState.resume',
message: Message(text: 'hey'), (tester) async {
channelType: 'test', await tester.runAsync(() async {
channelId: 'testid', final mockClient = MockClient();
user: User(id: 'other user'), const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
); );
eventStreamController.add(event);
await untilCalled(showLocalNotificationMock(event)); await tester.pumpWidget(streamChatCore);
verify(showLocalNotificationMock(event)).called(1); expect(find.byKey(streamChatCoreKey), findsOneWidget);
eventStreamController.close(); expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(mockClient.on()).thenAnswer((_) => Stream.value(event));
when(mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey),
);
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.paused);
await Future.delayed(const Duration(seconds: 1));
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.resumed);
verify(mockClient.connect()).called(1);
});
},
);
testWidgets(
'streamChatCoreState.userStream should emit all the user events '
'provided by client',
(tester) async {
await tester.runAsync(() async {
final userController = StreamController<OwnUser>();
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
);
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
when(mockClient.state.userStream)
.thenAnswer((_) => userController.stream);
final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey),
);
final ownUser = OwnUser(id: 'testUserId');
userController.add(ownUser);
await expectLater(
streamChatCoreState.userStream,
emits(ownUser),
);
addTearDown(() {
userController.close();
});
});
}, },
); );
} }
@@ -0,0 +1,572 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:stream_chat_flutter_core/src/user_list_core.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart';
void main() {
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
List<User> _generateUsers({
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return User(
id: 'testId$index',
role: 'testRole$index',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
lastActive: DateTime.now(),
online: true,
banned: false,
extraData: {
'name': '${alphabets[index]}-testName',
},
);
},
);
}
test(
'should throw assertion error in case listBuilder is null',
() {
final userListCore = () => UserListCore(
listBuilder: null,
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
);
expect(userListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case loadingBuilder is null',
() {
final userListCore = () => UserListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: null,
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
);
expect(userListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case emptyBuilder is null',
() {
final userListCore = () => UserListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: null,
errorBuilder: (Object error) => Offstage(),
);
expect(userListCore, throwsA(isA<AssertionError>()));
},
);
test(
'should throw assertion error in case errorBuilder is null',
() {
final userListCore = () => UserListCore(
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: null,
);
expect(userListCore, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'should throw if UserListCore is used where UsersBloc is not present '
'in the widget tree',
(tester) async {
const userListCoreKey = Key('userListCore');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
);
await tester.pumpWidget(userListCore);
expect(find.byKey(userListCoreKey), findsNothing);
expect(tester.takeException(), isInstanceOf<Exception>());
},
);
testWidgets(
'should render UserListCore if used with UsersBloc as an ancestor',
(tester) async {
const userListCoreKey = Key('userListCore');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
);
expect(find.byKey(userListCoreKey), findsOneWidget);
},
);
testWidgets(
'should assign loadData and paginateData callback to '
'UserListController if passed',
(tester) async {
const userListCoreKey = Key('userListCore');
final controller = UserListController();
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
userListController: controller,
);
expect(controller.loadData, isNull);
expect(controller.paginateData, isNull);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
);
expect(find.byKey(userListCoreKey), findsOneWidget);
expect(controller.loadData, isNotNull);
expect(controller.paginateData, isNotNull);
},
);
testWidgets(
'should build error widget if usersBlocState.usersStream emits error',
(tester) async {
const userListCoreKey = Key('userListCore');
const errorWidgetKey = Key('errorWidget');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Container(key: errorWidgetKey),
);
final mockClient = MockClient();
const error = 'Error! Error! Error!';
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenThrow(error);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(errorWidgetKey), findsOneWidget);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'should build empty widget if usersBlocState.usersStream emits empty data',
(tester) async {
const userListCoreKey = Key('userListCore');
const emptyWidgetKey = Key('emptyWidget');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Offstage(),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey),
errorBuilder: (Object error) => Offstage(),
);
final mockClient = MockClient();
const users = <User>[];
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(emptyWidgetKey), findsOneWidget);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'should build list widget if usersBlocState.usersStream emits some data',
(tester) async {
const userListCoreKey = Key('userListCore');
const listWidgetKey = Key('listWidget');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, __) => Container(key: listWidgetKey),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
);
final mockClient = MockClient();
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'should build list widget with grouped data if groupAlphabetically is true',
(tester) async {
const userListCoreKey = Key('userListCore');
const listWidgetKey = Key('listWidget');
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, items) => Container(
key: listWidgetKey,
child: ListView(
children: items.map((e) {
return Container(
key: Key(e.key),
child: e.when(
headerItem: (heading) => Text(heading),
userItem: (user) => Text(user.id),
),
);
}).toList(growable: false),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
groupAlphabetically: true,
);
final mockClient = MockClient();
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
for (final user in users) {
expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget);
expect(find.byKey(Key('USER-${user.id}')), findsOneWidget);
}
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'should build list widget with paginated data '
'on calling channelListCoreState.paginateData',
(tester) async {
const userListCoreKey = Key('userListCore');
const listWidgetKey = Key('listWidget');
const pagination = PaginationParams();
final userListCore = UserListCore(
key: userListCoreKey,
listBuilder: (_, items) => Container(
key: listWidgetKey,
child: ListView(
children: items.map((e) {
return Container(
key: Key(e.key),
child: e.when(
headerItem: (heading) => Text(heading),
userItem: (user) => Text(user.id),
),
);
}).toList(growable: false),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
pagination: pagination,
groupAlphabetically: true,
);
final mockClient = MockClient();
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: UsersBloc(
child: userListCore,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
for (final user in users) {
expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget);
expect(find.byKey(Key('USER-${user.id}')), findsOneWidget);
}
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
final userListCoreState = tester.state<UserListCoreState>(
find.byKey(userListCoreKey),
);
final offset = users.length;
final paginatedUsers = _generateUsers(offset: offset);
final updatedPagination = pagination.copyWith(offset: offset);
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: updatedPagination,
)).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers);
await userListCoreState.paginateData();
await tester.pumpAndSettle();
for (final user in users + paginatedUsers) {
expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget);
expect(find.byKey(Key('USER-${user.id}')), findsOneWidget);
}
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: updatedPagination,
)).called(1);
},
);
testWidgets(
'should rebuild UserListCore with updated widget data '
'on calling setState()',
(tester) async {
const pagination = PaginationParams();
StateSetter _stateSetter;
int limit = pagination.limit;
const userListCoreKey = Key('userListCore');
const listWidgetKey = Key('listWidget');
UserListCore userListCoreBuilder(int limit) => UserListCore(
key: userListCoreKey,
listBuilder: (_, items) => Container(
key: listWidgetKey,
child: ListView(
children: items.map((e) {
return Container(
key: Key(e.key),
child: e.when(
headerItem: (heading) => Text(heading),
userItem: (user) => Text(user.id),
),
);
}).toList(growable: false),
),
),
loadingBuilder: (BuildContext context) => Offstage(),
emptyBuilder: (BuildContext context) => Offstage(),
errorBuilder: (Object error) => Offstage(),
pagination: pagination.copyWith(limit: limit),
groupAlphabetically: true,
);
final mockClient = MockClient();
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: StreamChatCore(
client: mockClient,
child: UsersBloc(
child: StatefulBuilder(builder: (context, stateSetter) {
// Assigning stateSetter for rebuilding UserListCore
_stateSetter = stateSetter;
return userListCoreBuilder(limit);
}),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(listWidgetKey), findsOneWidget);
for (final user in users) {
expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget);
expect(find.byKey(Key('USER-${user.id}')), findsOneWidget);
}
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
// Rebuilding UserListCore with new pagination limit
_stateSetter(() => limit = 6);
final updatedUsers = _generateUsers(count: limit);
final updatedPagination = pagination.copyWith(limit: limit);
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: updatedPagination,
)).thenAnswer((_) async => QueryUsersResponse()..users = updatedUsers);
await tester.pumpAndSettle();
for (final user in updatedUsers) {
expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget);
expect(find.byKey(Key('USER-${user.id}')), findsOneWidget);
}
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: updatedPagination,
)).called(1);
},
);
}
@@ -0,0 +1,327 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/stream_chat_core.dart';
import 'package:stream_chat_flutter_core/src/users_bloc.dart';
import 'package:mockito/mockito.dart';
import 'matchers/users_matcher.dart';
import 'mocks.dart';
void main() {
List<User> _generateUsers({
int count = 3,
int offset = 0,
}) {
return List.generate(
count,
(index) {
index = index + offset;
return User(
id: 'testId$index',
role: 'testRole$index',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
lastActive: DateTime.now(),
online: true,
banned: false,
extraData: {'extra_data_key': 'extraDataValue'},
);
},
);
}
test(
'should throw assertion error if child is null',
() async {
const usersBlocKey = Key('usersBloc');
final usersBloc = () => UsersBloc(
key: usersBlocKey,
child: null,
);
expect(usersBloc, throwsA(isA<AssertionError>()));
},
);
testWidgets(
'usersBlocState.queryUsers() should throw if used where '
'StreamChat is not present in the widget tree',
(tester) async {
const usersBlocKey = Key('usersBloc');
const childKey = Key('child');
final usersBloc = UsersBloc(
key: usersBlocKey,
child: Offstage(key: childKey),
);
await tester.pumpWidget(usersBloc);
expect(find.byKey(usersBlocKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final usersBlocState = tester.state<UsersBlocState>(
find.byKey(usersBlocKey),
);
try {
await usersBlocState.queryUsers();
} catch (e) {
expect(e, isInstanceOf<Exception>());
}
},
);
testWidgets(
'usersBlocState.queryUsers() should emit data through usersStream',
(tester) async {
const usersBlocKey = Key('usersBloc');
const childKey = Key('child');
final usersBloc = UsersBloc(
key: usersBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: usersBloc,
),
);
final usersBlocState = tester.state<UsersBlocState>(
find.byKey(usersBlocKey),
);
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
usersBlocState.queryUsers();
await expectLater(
usersBlocState.usersStream,
emits(isSameUserListAs(users)),
);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'usersBlocState.usersStream should emit error '
'if client.queryUsers() throws',
(tester) async {
const usersBlocKey = Key('usersBloc');
const childKey = Key('child');
final usersBloc = UsersBloc(
key: usersBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: usersBloc,
),
);
final usersBlocState = tester.state<UsersBlocState>(
find.byKey(usersBlocKey),
);
final error = 'Error! Error! Error!';
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenThrow(error);
usersBlocState.queryUsers();
await expectLater(
usersBlocState.usersStream,
emitsError(error),
);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
},
);
testWidgets(
'calling usersBlocState.queryUsers() again with an offset '
'should emit new data through usersStream and also emit loading state '
'through queryUsersLoading',
(tester) async {
const usersBlocKey = Key('usersBloc');
const childKey = Key('child');
final usersBloc = UsersBloc(
key: usersBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: usersBloc,
),
);
final usersBlocState = tester.state<UsersBlocState>(
find.byKey(usersBlocKey),
);
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
usersBlocState.queryUsers();
await expectLater(
usersBlocState.usersStream,
emits(isSameUserListAs(users)),
);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
final offset = users.length;
final paginatedUsers = _generateUsers(offset: offset);
final pagination = PaginationParams(offset: offset);
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: pagination,
)).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers);
usersBlocState.queryUsers(pagination: pagination);
await Future.wait([
expectLater(
usersBlocState.queryUsersLoading,
emitsInOrder([true, false]),
),
expectLater(
usersBlocState.usersStream,
emits(isSameUserListAs(users + paginatedUsers)),
),
]);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: pagination,
)).called(1);
},
);
testWidgets(
'calling usersBlocState.queryUsers() again with an offset '
'should emit error through queryUsersLoading if '
'client.queryUsers() throws',
(tester) async {
const usersBlocKey = Key('usersBloc');
const childKey = Key('child');
final usersBloc = UsersBloc(
key: usersBlocKey,
child: Offstage(key: childKey),
);
final mockClient = MockClient();
await tester.pumpWidget(
StreamChatCore(
client: mockClient,
child: usersBloc,
),
);
final usersBlocState = tester.state<UsersBlocState>(
find.byKey(usersBlocKey),
);
final users = _generateUsers();
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).thenAnswer((_) async => QueryUsersResponse()..users = users);
usersBlocState.queryUsers();
await expectLater(
usersBlocState.usersStream,
emits(isSameUserListAs(users)),
);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: anyNamed('pagination'),
)).called(1);
final offset = users.length;
final pagination = PaginationParams(offset: offset);
final error = 'Error! Error! Error!';
when(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: pagination,
)).thenThrow(error);
usersBlocState.queryUsers(pagination: pagination);
await expectLater(
usersBlocState.queryUsersLoading,
emitsError(error),
);
verify(mockClient.queryUsers(
filter: anyNamed('filter'),
sort: anyNamed('sort'),
options: anyNamed('options'),
pagination: pagination,
)).called(1);
},
);
}
@@ -1,3 +1,8 @@
## 1.5.1
* Improved test coverage to > 95%
* Minor fixes and improvements
## 1.5.0 ## 1.5.0
* Update llc dependency * Update llc dependency
@@ -32,7 +32,7 @@ class ChannelQueryDao extends DatabaseAccessor<MoorChatDatabase>
Future<void> updateChannelQueries( Future<void> updateChannelQueries(
Map<String, dynamic> filter, Map<String, dynamic> filter,
List<String> cids, { List<String> cids, {
bool clearQueryCache, bool clearQueryCache = false,
}) async => }) async =>
transaction(() async { transaction(() async {
final hash = _computeHash(filter); final hash = _computeHash(filter);
@@ -57,6 +57,14 @@ class ChannelQueryDao extends DatabaseAccessor<MoorChatDatabase>
}); });
}); });
///
Future<List<String>> getCachedChannelCids(Map<String, dynamic> filter) {
final hash = _computeHash(filter);
return (select(channelQueries)..where((c) => c.queryHash.equals(hash)))
.map((c) => c.channelCid)
.get();
}
/// Get list of channels by filter, sort and paginationParams /// Get list of channels by filter, sort and paginationParams
Future<List<ChannelModel>> getChannels({ Future<List<ChannelModel>> getChannels({
Map<String, dynamic> filter, Map<String, dynamic> filter,
@@ -72,12 +80,7 @@ class ChannelQueryDao extends DatabaseAccessor<MoorChatDatabase>
return true; return true;
}(), ''); }(), '');
final hash = _computeHash(filter); final cachedChannelCids = await getCachedChannelCids(filter);
final cachedChannelCids = await (select(channelQueries)
..where((c) => c.queryHash.equals(hash)))
.map((c) => c.channelCid)
.get();
final query = select(channels)..where((c) => c.cid.isIn(cachedChannelCids)); final query = select(channels)..where((c) => c.cid.isIn(cachedChannelCids));
final cachedChannels = await (query.join([ final cachedChannels = await (query.join([
@@ -2,14 +2,13 @@ import 'package:moor/moor.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
import 'package:stream_chat_persistence/src/entity/connection_events.dart'; import 'package:stream_chat_persistence/src/entity/connection_events.dart';
import 'package:stream_chat_persistence/src/entity/users.dart';
import 'package:stream_chat_persistence/src/mapper/mapper.dart'; import 'package:stream_chat_persistence/src/mapper/mapper.dart';
part 'connection_event_dao.g.dart'; part 'connection_event_dao.g.dart';
/// The Data Access Object for operations in [ConnectionEvents] table. /// The Data Access Object for operations in [ConnectionEvents] table.
@UseDao(tables: [ConnectionEvents, Users]) @UseDao(tables: [ConnectionEvents])
class ConnectionEventDao extends DatabaseAccessor<MoorChatDatabase> class ConnectionEventDao extends DatabaseAccessor<MoorChatDatabase>
with _$ConnectionEventDaoMixin { with _$ConnectionEventDaoMixin {
/// Creates a new connection event dao instance /// Creates a new connection event dao instance
@@ -9,5 +9,4 @@ part of 'connection_event_dao.dart';
mixin _$ConnectionEventDaoMixin on DatabaseAccessor<MoorChatDatabase> { mixin _$ConnectionEventDaoMixin on DatabaseAccessor<MoorChatDatabase> {
$ConnectionEventsTable get connectionEvents => $ConnectionEventsTable get connectionEvents =>
attachedDatabase.connectionEvents; attachedDatabase.connectionEvents;
$UsersTable get users => attachedDatabase.users;
} }
@@ -72,7 +72,7 @@ class MessageDao extends DatabaseAccessor<MoorChatDatabase>
/// [Messages.channelCid] with [cid] /// [Messages.channelCid] with [cid]
Future<List<Message>> getThreadMessages(String cid) async => Future<List<Message>> getThreadMessages(String cid) async =>
Future.wait(await (select(messages).join([ Future.wait(await (select(messages).join([
leftOuterJoin(users, messages.userId.equalsExp(_users.id)), leftOuterJoin(_users, messages.userId.equalsExp(_users.id)),
leftOuterJoin(_pinnedByUsers, leftOuterJoin(_pinnedByUsers,
messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)),
]) ])
@@ -89,19 +89,37 @@ class MessageDao extends DatabaseAccessor<MoorChatDatabase>
PaginationParams options, PaginationParams options,
}) async { }) async {
final msgList = await Future.wait(await (select(messages).join([ final msgList = await Future.wait(await (select(messages).join([
innerJoin(_users, messages.userId.equalsExp(_users.id)), leftOuterJoin(_users, messages.userId.equalsExp(_users.id)),
innerJoin( leftOuterJoin(
_pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), _pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)),
]) ])
..where(isNotNull(messages.parentId))
..where(messages.parentId.equals(parentId)) ..where(messages.parentId.equals(parentId))
..orderBy([OrderingTerm.asc(messages.createdAt)])) ..orderBy([OrderingTerm.asc(messages.createdAt)]))
.map(_messageFromJoinRow) .map(_messageFromJoinRow)
.get()); .get());
if (options?.lessThan != null && msgList.isNotEmpty) { if (msgList.isNotEmpty) {
final lessThanIndex = msgList.indexWhere((m) => m.id == options.lessThan); if (options?.lessThan != null) {
final lessThanIndex = msgList.indexWhere(
(m) => m.id == options.lessThan,
);
if (lessThanIndex != -1) {
msgList.removeRange(lessThanIndex, msgList.length); msgList.removeRange(lessThanIndex, msgList.length);
} }
}
if (options?.greaterThanOrEqual != null) {
final greaterThanIndex = msgList.indexWhere(
(m) => m.id == options.greaterThanOrEqual,
);
if (greaterThanIndex != -1) {
msgList.removeRange(0, greaterThanIndex);
}
}
if (options?.limit != null) {
return msgList.take(options.limit).toList();
}
}
return msgList; return msgList;
} }
@@ -123,6 +141,7 @@ class MessageDao extends DatabaseAccessor<MoorChatDatabase>
.map(_messageFromJoinRow) .map(_messageFromJoinRow)
.get()); .get());
if (msgList.isNotEmpty) {
if (messagePagination?.lessThan != null) { if (messagePagination?.lessThan != null) {
final lessThanIndex = msgList.indexWhere( final lessThanIndex = msgList.indexWhere(
(m) => m.id == messagePagination.lessThan, (m) => m.id == messagePagination.lessThan,
@@ -142,6 +161,7 @@ class MessageDao extends DatabaseAccessor<MoorChatDatabase>
if (messagePagination?.limit != null) { if (messagePagination?.limit != null) {
return msgList.take(messagePagination.limit).toList(); return msgList.take(messagePagination.limit).toList();
} }
}
return msgList; return msgList;
} }
@@ -89,19 +89,37 @@ class PinnedMessageDao extends DatabaseAccessor<MoorChatDatabase>
PaginationParams options, PaginationParams options,
}) async { }) async {
final msgList = await Future.wait(await (select(pinnedMessages).join([ final msgList = await Future.wait(await (select(pinnedMessages).join([
innerJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)),
innerJoin(_pinnedByUsers, leftOuterJoin(_pinnedByUsers,
pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)),
]) ])
..where(isNotNull(pinnedMessages.parentId))
..where(pinnedMessages.parentId.equals(parentId)) ..where(pinnedMessages.parentId.equals(parentId))
..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)]))
.map(_messageFromJoinRow) .map(_messageFromJoinRow)
.get()); .get());
if (msgList.isNotEmpty) {
if (options?.lessThan != null) { if (options?.lessThan != null) {
final lessThanIndex = msgList.indexWhere((m) => m.id == options.lessThan); final lessThanIndex = msgList.indexWhere(
(m) => m.id == options.lessThan,
);
if (lessThanIndex != -1) {
msgList.removeRange(lessThanIndex, msgList.length); msgList.removeRange(lessThanIndex, msgList.length);
} }
}
if (options?.greaterThanOrEqual != null) {
final greaterThanIndex = msgList.indexWhere(
(m) => m.id == options.greaterThanOrEqual,
);
if (greaterThanIndex != -1) {
msgList.removeRange(0, greaterThanIndex);
}
}
if (options?.limit != null) {
return msgList.take(options.limit).toList();
}
}
return msgList; return msgList;
} }
@@ -123,6 +141,7 @@ class PinnedMessageDao extends DatabaseAccessor<MoorChatDatabase>
.map(_messageFromJoinRow) .map(_messageFromJoinRow)
.get()); .get());
if (msgList.isNotEmpty) {
if (messagePagination?.lessThan != null) { if (messagePagination?.lessThan != null) {
final lessThanIndex = msgList.indexWhere( final lessThanIndex = msgList.indexWhere(
(m) => m.id == messagePagination.lessThan, (m) => m.id == messagePagination.lessThan,
@@ -142,6 +161,7 @@ class PinnedMessageDao extends DatabaseAccessor<MoorChatDatabase>
if (messagePagination?.limit != null) { if (messagePagination?.limit != null) {
return msgList.take(messagePagination.limit).toList(); return msgList.take(messagePagination.limit).toList();
} }
}
return msgList; return msgList;
} }
@@ -20,4 +20,10 @@ class UserDao extends DatabaseAccessor<MoorChatDatabase> with _$UserDaoMixin {
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrReplace,
), ),
); );
/// Returns the list of all the users stored in db
Future<List<User>> getUsers() =>
(select(users)..orderBy([(u) => OrderingTerm.desc(u.createdAt)]))
.map((it) => it.toUser())
.get();
} }
@@ -1,22 +1,15 @@
import 'package:meta/meta.dart';
import 'package:moor/ffi.dart';
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/converter/converter.dart'; import 'package:stream_chat_persistence/src/converter/converter.dart';
import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/dao/dao.dart';
import 'package:stream_chat_persistence/src/db/shared/shared_db.dart';
import 'package:stream_chat_persistence/src/entity/entity.dart'; import 'package:stream_chat_persistence/src/entity/entity.dart';
part 'moor_chat_database.g.dart'; export 'shared/shared_db.dart';
LazyDatabase _openConnection( part 'moor_chat_database.g.dart';
String userId, {
bool logStatements = false,
bool persistOnDisk = true,
}) =>
LazyDatabase(() async => SharedDB.constructDatabase(
userId,
logStatements: logStatements,
persistOnDisk: persistOnDisk,
));
/// A chat database implemented using moor /// A chat database implemented using moor
@UseMoor(tables: [ @UseMoor(tables: [
@@ -43,14 +36,9 @@ LazyDatabase _openConnection(
class MoorChatDatabase extends _$MoorChatDatabase { class MoorChatDatabase extends _$MoorChatDatabase {
/// Creates a new moor chat database instance /// Creates a new moor chat database instance
MoorChatDatabase( MoorChatDatabase(
this._userId, { this._userId,
logStatements = false, QueryExecutor executor,
bool persistOnDisk = true, ) : super(executor);
}) : super(_openConnection(
_userId,
logStatements: logStatements,
persistOnDisk: persistOnDisk,
));
/// Instantiate a new database instance /// Instantiate a new database instance
MoorChatDatabase.connect( MoorChatDatabase.connect(
@@ -58,6 +46,10 @@ class MoorChatDatabase extends _$MoorChatDatabase {
DatabaseConnection connection, DatabaseConnection connection,
) : super.connect(connection); ) : super.connect(connection);
/// Custom constructor used only for testing
@visibleForTesting
MoorChatDatabase.testable(this._userId) : super(VmDatabase.memory());
final String _userId; final String _userId;
/// User id to which the database is connected /// User id to which the database is connected
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'dart:io'; import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
@@ -14,17 +15,40 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
/// A Helper class to construct new instances of [MoorChatDatabase] specifically /// A Helper class to construct new instances of [MoorChatDatabase] specifically
/// for native platform applications /// for native platform applications
class SharedDB { class SharedDB {
/// Returns a new instance of [VmDatabase] created using [userId] /// Returns a new instance of [MoorChatDatabase].
/// on a regular isolate. static MoorChatDatabase constructDatabase(
///
/// Generally used with [ConnectionMode.regular].
static Future<VmDatabase> constructDatabase(
String userId, { String userId, {
bool logStatements = false, bool logStatements = false,
bool persistOnDisk = true, ConnectionMode connectionMode = ConnectionMode.regular,
}) async { }) {
final dbName = 'db_$userId'; final dbName = 'db_$userId';
if (persistOnDisk) { if (connectionMode == ConnectionMode.background) {
return MoorChatDatabase.connect(
userId,
DatabaseConnection.delayed(Future(() async {
final isolate = await _createMoorIsolate(
dbName,
logStatements: logStatements,
);
return isolate.connect();
})),
);
}
return MoorChatDatabase(
userId,
LazyDatabase(
() async => _constructDatabase(
dbName,
logStatements: logStatements,
),
),
);
}
static Future<VmDatabase> _constructDatabase(
String dbName, {
bool logStatements = false,
}) async {
if (Platform.isIOS || Platform.isAndroid) { if (Platform.isIOS || Platform.isAndroid) {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, '$dbName.sqlite'); final path = join(dir.path, '$dbName.sqlite');
@@ -35,7 +59,6 @@ class SharedDB {
final file = File('$dbName.sqlite'); final file = File('$dbName.sqlite');
return VmDatabase(file, logStatements: logStatements); return VmDatabase(file, logStatements: logStatements);
} }
}
return VmDatabase.memory(logStatements: logStatements); return VmDatabase.memory(logStatements: logStatements);
} }
@@ -69,27 +92,6 @@ class SharedDB {
return await receivePort.first as MoorIsolate; return await receivePort.first as MoorIsolate;
} }
/// Returns a new instance of [MoorChatDatabase] using the factory constructor
/// [MoorChatDatabase.connect] created on a background isolate.
///
/// Generally used with [ConnectionMode.background].
static MoorChatDatabase constructMoorChatDatabase(
String userId, {
bool logStatements = false,
}) {
final dbName = 'db_$userId';
return MoorChatDatabase.connect(
userId,
DatabaseConnection.delayed(Future(() async {
final isolate = await _createMoorIsolate(
dbName,
logStatements: logStatements,
);
return isolate.connect();
})),
);
}
} }
class _IsolateStartRequest { class _IsolateStartRequest {
@@ -1,29 +1,17 @@
import 'package:moor/backends.dart'; // coverage:ignore-file
import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
import 'package:stream_chat_persistence/stream_chat_persistence.dart'; import 'package:stream_chat_persistence/stream_chat_persistence.dart';
/// A Helper class to construct new instances of [MoorChatDatabase] /// A Helper class to construct new instances of [MoorChatDatabase]
class SharedDB { class SharedDB {
/// Returns a new instance of database. /// Returns a new instance of [MoorChatDatabase].
/// static MoorChatDatabase constructDatabase(
/// Generally used with [ConnectionMode.regular].
static Future<DelegatedDatabase> constructDatabase(
String userId, { String userId, {
bool logStatements = false, bool logStatements = false,
bool persistOnDisk = true, ConnectionMode connectionMode = ConnectionMode.regular,
}) { }) {
throw UnsupportedError( throw UnsupportedError(
'No implementation of the constructDatabase api provided'); 'No implementation of the constructDatabase api provided',
} );
/// Return a new instance of moor chat database.
///
/// Generally used with [ConnectionMode.background].
static MoorChatDatabase constructMoorChatDatabase(
String userId, {
bool logStatements = false,
}) {
throw UnsupportedError(
'No implementation of the constructMoorChatDatabase api provided');
} }
} }
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor_web.dart'; import 'package:moor/moor_web.dart';
import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart'; import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart';
@@ -6,27 +7,14 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
/// A Helper class to construct new instances of [MoorChatDatabase] specifically /// A Helper class to construct new instances of [MoorChatDatabase] specifically
/// for Web applications /// for Web applications
class SharedDB { class SharedDB {
/// Returns a new instance of [WebDatabase] created using [userId]. /// Returns a new instance of [MoorChatDatabase].
/// static MoorChatDatabase constructDatabase(
/// Generally used with [ConnectionMode.regular].
static Future<WebDatabase> constructDatabase(
String userId, {
bool logStatements = false,
bool persistOnDisk = true, // ignored on web
}) async {
final dbName = 'db_$userId';
return WebDatabase(dbName, logStatements: logStatements);
}
/// Returns a new instance of [MoorChatDatabase] creating using the
/// default constructor.
///
/// Generally used with [ConnectionMode.background].
static MoorChatDatabase constructMoorChatDatabase(
String userId, { String userId, {
bool logStatements = false, bool logStatements = false,
ConnectionMode connectionMode = ConnectionMode.regular, // Ignored on web
}) { }) {
final dbName = 'db_$userId'; final dbName = 'db_$userId';
return MoorChatDatabase(dbName, logStatements: logStatements); final queryExecutor = WebDatabase(dbName, logStatements: logStatements);
return MoorChatDatabase(userId, queryExecutor);
} }
} }
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
/// Represents a [ChannelQueries] table in [MoorChatDatabase]. /// Represents a [ChannelQueries] table in [MoorChatDatabase].
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart';
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart';
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
/// Represents a [Members] table in [MoorChatDatabase]. /// Represents a [Members] table in [MoorChatDatabase].
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/converter/list_converter.dart'; import 'package:stream_chat_persistence/src/converter/list_converter.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart';
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/entity/messages.dart'; import 'package:stream_chat_persistence/src/entity/messages.dart';
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart';
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
/// Represents a [Reads] table in [MoorChatDatabase]. /// Represents a [Reads] table in [MoorChatDatabase].
@@ -1,3 +1,4 @@
// coverage:ignore-file
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart';
@@ -46,14 +46,14 @@ extension ChannelModelX on ChannelModel {
id: id, id: id,
type: type, type: type,
cid: cid, cid: cid,
config: config.toJson(), config: config?.toJson(),
frozen: frozen, frozen: frozen,
lastMessageAt: lastMessageAt, lastMessageAt: lastMessageAt,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
deletedAt: deletedAt, deletedAt: deletedAt,
memberCount: memberCount, memberCount: memberCount,
createdById: createdBy.id, createdById: createdBy?.id,
extraData: extraData, extraData: extraData,
); );
} }
@@ -4,7 +4,6 @@ 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/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
import 'package:stream_chat_persistence/src/db/shared/shared_db.dart';
/// Various connection modes on which [StreamChatPersistenceClient] can work /// Various connection modes on which [StreamChatPersistenceClient] can work
enum ConnectionMode { enum ConnectionMode {
@@ -15,6 +14,9 @@ enum ConnectionMode {
background, background,
} }
/// Signature for a function which provides instance of [MoorChatDatabase]
typedef DatabaseProvider = MoorChatDatabase Function(String, ConnectionMode);
final _levelEmojiMapper = { final _levelEmojiMapper = {
Level.INFO: '', Level.INFO: '',
Level.WARNING: '⚠️', Level.WARNING: '⚠️',
@@ -36,26 +38,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
_logger.onRecord.listen(logHandlerFunction ?? _defaultLogHandler); _logger.onRecord.listen(logHandlerFunction ?? _defaultLogHandler);
} }
/// A function that has a parameter of type [LogRecord].
/// This is called on every new log record.
/// By default the client will use the handler returned by
/// [_getDefaultLogHandler].
/// Setting it you can handle the log messages directly instead of have them
/// written to stdout,
/// this is very convenient if you use an error tracking tool or if you want
/// to centralize your logs into one facility.
///
/// ```dart
/// myLogHandlerFunction = (LogRecord record) {
/// // do something with the record (ie. send it to Sentry or Fabric)
/// }
///
/// final client = StreamChatPersistenceClient(
/// logHandlerFunction: myLogHandlerFunction,
/// );
///```
LogHandlerFunction logHandlerFunction;
/// [MoorChatDatabase] instance used by this client. /// [MoorChatDatabase] instance used by this client.
@visibleForTesting @visibleForTesting
MoorChatDatabase db; MoorChatDatabase db;
@@ -84,24 +66,25 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
return ret; return ret;
} }
MoorChatDatabase _defaultDatabaseProvider(
String userId,
ConnectionMode mode,
) =>
SharedDB.constructDatabase(userId, connectionMode: mode);
@override @override
Future<void> connect(String userId) async { Future<void> connect(
String userId, {
DatabaseProvider databaseProvider, // Used only for testing
}) async {
if (db != null) { if (db != null) {
throw Exception( throw Exception(
'An instance of StreamChatDatabase is already connected.\n' 'An instance of StreamChatDatabase is already connected.\n'
'disconnect the previous instance before connecting again.', 'disconnect the previous instance before connecting again.',
); );
} }
switch (_connectionMode) { db = databaseProvider?.call(userId, _connectionMode) ??
case ConnectionMode.regular: _defaultDatabaseProvider(userId, _connectionMode);
_logger.info('Connecting on a regular isolate');
db = MoorChatDatabase(userId);
return;
case ConnectionMode.background:
_logger.info('Connecting on background isolate');
db = SharedDB.constructMoorChatDatabase(userId);
return;
}
} }
@override @override
@@ -259,9 +242,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient {
@override @override
Future<void> updateChannelQueries( Future<void> updateChannelQueries(
Map<String, dynamic> filter, Map<String, dynamic> filter,
List<String> cids, List<String> cids, {
bool clearQueryCache, bool clearQueryCache = false,
) => }) =>
_readProtected(() async { _readProtected(() async {
_logger.info('updateChannelQueries'); _logger.info('updateChannelQueries');
return db.channelQueryDao.updateChannelQueries( return db.channelQueryDao.updateChannelQueries(
@@ -0,0 +1,798 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for moor.",
"version": "0.1.0-dev-preview"
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "channels",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "type",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "cid",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "config",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
},
{
"name": "frozen",
"moor_type": "ColumnType.boolean",
"nullable": false,
"customConstraints": null,
"default_dart": "Constant(false)",
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_message_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "updated_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "deleted_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "member_count",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_by_id",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "extra_data",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
}
],
"is_virtual": false,
"explicit_pk": [
"cid"
]
}
},
{
"id": 1,
"references": [],
"type": "table",
"data": {
"name": "messages",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "message_text",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "attachments",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "ListConverter<String>()",
"dart_type_name": "List<String>"
}
},
{
"name": "status",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MessageSendingStatusConverter()",
"dart_type_name": "MessageSendingStatus"
}
},
{
"name": "type",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "mentioned_users",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "ListConverter<String>()",
"dart_type_name": "List<String>"
}
},
{
"name": "reaction_counts",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<int>()",
"dart_type_name": "Map<String, int>"
}
},
{
"name": "reaction_scores",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<int>()",
"dart_type_name": "Map<String, int>"
}
},
{
"name": "parent_id",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "quoted_message_id",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "reply_count",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "show_in_channel",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "shadowed",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "command",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"moor_type": "ColumnType.datetime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "updated_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "deleted_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "user_id",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "channel_cid",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": "NULLABLE REFERENCES channels(cid) ON DELETE CASCADE",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "extra_data",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
}
],
"is_virtual": false,
"explicit_pk": [
"id"
]
}
},
{
"id": 2,
"references": [],
"type": "table",
"data": {
"name": "reactions",
"was_declared_in_moor": false,
"columns": [
{
"name": "user_id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "message_id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": "REFERENCES messages(id) ON DELETE CASCADE",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "type",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"moor_type": "ColumnType.datetime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "score",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "extra_data",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
}
],
"is_virtual": false,
"explicit_pk": [
"message_id",
"type",
"user_id"
]
}
},
{
"id": 3,
"references": [],
"type": "table",
"data": {
"name": "users",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "role",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "updated_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_active",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "online",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "banned",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "extra_data",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
}
],
"is_virtual": false,
"explicit_pk": [
"id"
]
}
},
{
"id": 4,
"references": [],
"type": "table",
"data": {
"name": "members",
"was_declared_in_moor": false,
"columns": [
{
"name": "user_id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "channel_cid",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": "REFERENCES channels(cid) ON DELETE CASCADE",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "role",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "invite_accepted_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "invite_rejected_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "invited",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "banned",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "shadow_banned",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "is_moderator",
"moor_type": "ColumnType.boolean",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"moor_type": "ColumnType.datetime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "updated_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"explicit_pk": [
"user_id",
"channel_cid"
]
}
},
{
"id": 5,
"references": [],
"type": "table",
"data": {
"name": "reads",
"was_declared_in_moor": false,
"columns": [
{
"name": "last_read",
"moor_type": "ColumnType.datetime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "user_id",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "channel_cid",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": "REFERENCES channels(cid) ON DELETE CASCADE",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "unread_messages",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"explicit_pk": [
"user_id",
"channel_cid"
]
}
},
{
"id": 6,
"references": [],
"type": "table",
"data": {
"name": "channel_queries",
"was_declared_in_moor": false,
"columns": [
{
"name": "query_hash",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "channel_cid",
"moor_type": "ColumnType.text",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"explicit_pk": [
"query_hash",
"channel_cid"
]
}
},
{
"id": 7,
"references": [],
"type": "table",
"data": {
"name": "connection_events",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"moor_type": "ColumnType.integer",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "own_user",
"moor_type": "ColumnType.text",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "MapConverter<Object>()",
"dart_type_name": "Map<String, Object>"
}
},
{
"name": "total_unread_count",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "unread_channels",
"moor_type": "ColumnType.integer",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_event_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_sync_at",
"moor_type": "ColumnType.datetime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"explicit_pk": [
"id"
]
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
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: 1.5.0 version: 1.5.1
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
@@ -18,10 +18,10 @@ dependencies:
path: ^1.7.0 path: ^1.7.0
path_provider: ^1.6.27 path_provider: ^1.6.27
sqlite3_flutter_libs: ^0.4.0+1 sqlite3_flutter_libs: ^0.4.0+1
stream_chat: ^1.5.0 stream_chat: ^1.5.1
dev_dependencies: dev_dependencies:
build_runner: ^1.11.0 build_runner: ^1.11.0
mockito: ^4.1.3
moor_generator: ^3.4.1 moor_generator: ^3.4.1
pedantic: ^1.9.2
test: ^1.15.7 test: ^1.15.7
@@ -0,0 +1,71 @@
import 'package:mockito/mockito.dart';
import 'package:stream_chat_persistence/src/dao/dao.dart';
import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
class MockChatDatabase extends Mock implements MoorChatDatabase {
UserDao _userDao;
@override
UserDao get userDao => _userDao ??= MockUserDao();
ChannelDao _channelDao;
@override
ChannelDao get channelDao => _channelDao ??= MockChannelDao();
MessageDao _messageDao;
@override
MessageDao get messageDao => _messageDao ??= MockMessageDao();
PinnedMessageDao _pinnedMessageDao;
@override
PinnedMessageDao get pinnedMessageDao =>
_pinnedMessageDao ??= MockPinnedMessageDao();
MemberDao _memberDao;
@override
MemberDao get memberDao => _memberDao ??= MockMemberDao();
ReactionDao _reactionDao;
@override
ReactionDao get reactionDao => _reactionDao ??= MockReactionDao();
ReadDao _readDao;
@override
ReadDao get readDao => _readDao ??= MockReadDao();
ChannelQueryDao _channelQueryDao;
@override
ChannelQueryDao get channelQueryDao =>
_channelQueryDao ??= MockChannelQueryDao();
ConnectionEventDao _connectionEventDao;
@override
ConnectionEventDao get connectionEventDao =>
_connectionEventDao ??= MockConnectionEventDao();
}
class MockUserDao extends Mock implements UserDao {}
class MockChannelDao extends Mock implements ChannelDao {}
class MockMessageDao extends Mock implements MessageDao {}
class MockPinnedMessageDao extends Mock implements PinnedMessageDao {}
class MockMemberDao extends Mock implements MemberDao {}
class MockReactionDao extends Mock implements ReactionDao {}
class MockReadDao extends Mock implements ReadDao {}
class MockChannelQueryDao extends Mock implements ChannelQueryDao {}
class MockConnectionEventDao extends Mock implements ConnectionEventDao {}
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:test/test.dart';
import 'package:stream_chat_persistence/src/converter/list_converter.dart';
void main() {
group('mapToDart', () {
final listConverter = ListConverter<String>();
test('should return null if nothing is provided', () {
final res = listConverter.mapToDart(null);
expect(res, isNull);
});
test('should throw type error if the provided json is not a list', () {
final json = {'test_key': 'testData'};
expect(
() => listConverter.mapToDart(jsonEncode(json)),
throwsA(isA<TypeError>()),
);
});
test('should throw type error if the provided json is not a list of String',
() {
final json = [22, 33, 44];
expect(
() => listConverter.mapToDart(jsonEncode(json)),
throwsA(isA<TypeError>()),
);
});
test('should return list of String if json data list is provided', () {
final data = ['data1', 'data2', 'data3'];
final res = listConverter.mapToDart(jsonEncode(data));
expect(res.length, data.length);
});
});
group('mapToSql', () {
final listConverter = ListConverter<String>();
test('should return null if nothing is provided', () {
final res = listConverter.mapToSql(null);
expect(res, isNull);
});
test('should return json string if data list is provided', () {
final data = ['data1', 'data2', 'data3'];
final res = listConverter.mapToSql(data);
expect(res, jsonEncode(data));
});
});
}
@@ -0,0 +1,64 @@
import 'dart:convert';
import 'package:test/test.dart';
import 'package:stream_chat_persistence/src/converter/map_converter.dart';
void main() {
group('mapToDart', () {
final mapConverter = MapConverter<String>();
test('should return null if nothing is provided', () {
final res = mapConverter.mapToDart(null);
expect(res, isNull);
});
test('should throw type error if the provided json is not a map', () {
const json = ['testData1', 'testData2', 'testData3'];
expect(
() => mapConverter.mapToDart(jsonEncode(json)),
throwsA(isA<TypeError>()),
);
});
test(
'should throw type error if the provided json is not a '
'map of String, String',
() {
const json = {'test_key': 22, 'test_key2': 33, 'test_key3': 44};
expect(
() => mapConverter.mapToDart(jsonEncode(json)),
throwsA(isA<TypeError>()),
);
},
);
test('should return map of String, String if json data is provided', () {
const data = {
'test_key': 'testValue',
'test_key2': 'testValue2',
'test_key3': 'testValue3',
};
final res = mapConverter.mapToDart(jsonEncode(data));
expect(res, data);
});
});
group('mapToSql', () {
final mapConverter = MapConverter<String>();
test('should return null if nothing is provided', () {
final res = mapConverter.mapToSql(null);
expect(res, isNull);
});
test('should return json string if data map is provided', () {
const data = {
'test_key': 'testValue',
'test_key2': 'testValue2',
'test_key3': 'testValue3',
};
final res = mapConverter.mapToSql(data);
expect(res, jsonEncode(data));
});
});
}
@@ -0,0 +1,33 @@
import 'package:test/test.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/converter/message_sending_status_converter.dart';
void main() {
group('mapToDart', () {
final statusConverter = MessageSendingStatusConverter();
test('should return null if nothing is provided', () {
final res = statusConverter.mapToDart(null);
expect(res, isNull);
});
test('should return expected status if status code is provided', () {
final res = statusConverter.mapToDart(3);
expect(res, MessageSendingStatus.updating);
});
});
group('mapToSql', () {
final statusConverter = MessageSendingStatusConverter();
test('should return null if nothing is provided', () {
final res = statusConverter.mapToSql(null);
expect(res, isNull);
});
test('should return expected code if the status is provided', () {
final res = statusConverter.mapToSql(MessageSendingStatus.updating);
expect(res, 3);
});
});
}
@@ -0,0 +1,130 @@
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/dao/channel_dao.dart';
import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
import 'package:test/test.dart';
void main() {
ChannelDao channelDao;
MoorChatDatabase database;
setUp(() {
database = MoorChatDatabase.testable('testUserId');
channelDao = database.channelDao;
});
test('getChannelByCid', () async {
const id = 'testId';
const cid = 'testCid';
const type = 'testType';
// Should be null initially
final channel = await channelDao.getChannelByCid(cid);
expect(channel, isNull);
// Saving a dummy channel
final dummyChannel = ChannelModel(
id: id,
type: type,
cid: cid,
config: ChannelConfig(),
);
await channelDao.updateChannels([dummyChannel]);
// Should match the dummy channel
final updatedChannel = await channelDao.getChannelByCid(cid);
expect(updatedChannel.id, id);
expect(updatedChannel.cid, cid);
expect(updatedChannel.type, type);
});
test('deleteChannelByCids', () async {
const id = 'testId';
const cid = 'testCid';
const type = 'testType';
// Saving a dummy channel
final dummyChannel = ChannelModel(
id: id,
type: type,
cid: cid,
config: ChannelConfig(),
);
await channelDao.updateChannels([dummyChannel]);
// Should match the dummy channel
final updatedChannel = await channelDao.getChannelByCid(cid);
expect(updatedChannel.id, id);
expect(updatedChannel.cid, cid);
expect(updatedChannel.type, type);
// Deleting the dummyChannel using cid
await channelDao.deleteChannelByCids([cid]);
// Fetched channel Should be null
final channel = await channelDao.getChannelByCid(cid);
expect(channel, isNull);
});
test('cids', () async {
// Should be empty initially
final cids = await channelDao.cids;
expect(cids, []);
const id = 'testId';
const cid = 'testCid';
const type = 'testType';
// Saving a dummy channel
final dummyChannel = ChannelModel(
id: id,
type: type,
cid: cid,
config: ChannelConfig(),
);
await channelDao.updateChannels([dummyChannel]);
// Should return the cid of the dummy channel
final updatedCids = await channelDao.cids;
expect(updatedCids, [cid]);
});
test('updateChannels', () async {
const id = 'testId';
const cid = 'testCid';
const type = 'testType';
// Should be null initially
final channel = await channelDao.getChannelByCid(cid);
expect(channel, isNull);
// Saving a dummy channel
final dummyChannel = ChannelModel(
id: id,
type: type,
cid: cid,
config: ChannelConfig(),
);
await channelDao.updateChannels([dummyChannel]);
// Should match the dummy channel
final updatedChannel = await channelDao.getChannelByCid(cid);
expect(updatedChannel.id, id);
expect(updatedChannel.cid, cid);
expect(updatedChannel.type, type);
// Updating the previously saved channel
const newType = 'newTestType';
final newChannel = dummyChannel.copyWith(type: newType);
await channelDao.updateChannels([newChannel]);
// Should match the new channel
final newUpdatedChannel = await channelDao.getChannelByCid(cid);
expect(newUpdatedChannel.id, id);
expect(newUpdatedChannel.cid, cid);
expect(newUpdatedChannel.type, newType);
});
tearDown(() async {
await database.disconnect();
});
}
@@ -0,0 +1,273 @@
import 'dart:math' as math;
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_persistence/src/dao/channel_query_dao.dart';
import 'package:stream_chat_persistence/src/db/moor_chat_database.dart';
import 'package:test/test.dart';
import '../utils/date_matcher.dart';
void main() {
MoorChatDatabase database;
ChannelQueryDao channelQueryDao;
setUp(() {
database = MoorChatDatabase.testable('testUserId');
channelQueryDao = database.channelQueryDao;
});
test('updateChannelQueries', () async {
const filter = {
'members': {
r'$in': ['testUserId'],
},
};
const cids = ['testCid1', 'testCid2', 'testCid3'];
final cachedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(cachedCids, isEmpty);
// Updating channel queries
await channelQueryDao.updateChannelQueries(filter, cids);
final updatedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(updatedCids, cids);
});
test('clear queryCache before updateChannelQueries', () async {
const filter = {
'members': {
r'$in': ['testUserId'],
},
};
const cids = ['testCid1', 'testCid2', 'testCid3'];
final cachedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(cachedCids, isEmpty);
// Updating channel queries
await channelQueryDao.updateChannelQueries(
filter,
cids,
clearQueryCache: true,
);
final updatedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(updatedCids, cids);
});
test('getCachedChannelCids', () async {
const filter = {
'members': {
r'$in': ['testUserId'],
},
};
const cids = ['testCid1', 'testCid2', 'testCid3'];
final cachedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(cachedCids, isEmpty);
// Updating channel queries
await channelQueryDao.updateChannelQueries(filter, cids);
final updatedCids = await channelQueryDao.getCachedChannelCids(filter);
expect(updatedCids, cids);
});
Future<List<ChannelModel>> _insertTestDataForGetChannel(
Map<String, Object> filter, {
int count = 3,
}) async {
final now = DateTime.now();
final userDao = database.userDao;
final channelDao = database.channelDao;
final cids = List.generate(count, (index) => 'testCid$index');
final users = List.generate(count, (index) => User(id: 'testId$index'));
final channels = List.generate(
count,
(index) => ChannelModel(
id: 'testId$index',
type: 'testType$index',
cid: cids[index],
createdBy: users[index],
config: ChannelConfig(),
extraData: {'test_custom_field': math.Random().nextInt(100)},
createdAt: now,
memberCount: math.Random().nextInt(100),
lastMessageAt: now.add(Duration(hours: index)),
),
).reversed.toList(growable: false);
await userDao.updateUsers(users);
await channelDao.updateChannels(channels);
await channelQueryDao.updateChannelQueries(filter, cids);
return channels;
}
group('getChannels', () {
const filter = {
'members': {
r'$in': ['testUserId'],
},
};
test('should return empty list of channels', () async {
final channels = await channelQueryDao.getChannels(filter: filter);
expect(channels, isEmpty);
});
test('should return all the inserted channels', () async {
// Inserting test data for get channels
final insertedChannels = await _insertTestDataForGetChannel(filter);
// Should match with the inserted channels
final updatedChannels = await channelQueryDao.getChannels(filter: filter);
expect(updatedChannels.length, insertedChannels.length);
for (var i = 0; i < updatedChannels.length; i++) {
final updatedChannel = updatedChannels[i];
final insertedChannel = insertedChannels[i];
// Should match all the basic details
expect(updatedChannel.id, insertedChannel.id);
expect(updatedChannel.type, insertedChannel.type);
expect(updatedChannel.cid, insertedChannel.cid);
expect(updatedChannel.memberCount, insertedChannel.memberCount);
// Should match createdAt date
expect(
updatedChannel.createdAt,
isSameDateAs(insertedChannel.createdAt),
);
// Should match lastMessageAt date
expect(
updatedChannel.lastMessageAt,
isSameDateAs(insertedChannel.lastMessageAt),
);
}
});
test(
'should return all the inserted channels along with pagination applied',
() async {
const offset = 5;
const limit = 15;
const pagination = PaginationParams(offset: offset, limit: limit);
// Inserting test data for get channels
final insertedChannels = await _insertTestDataForGetChannel(
filter,
count: 30,
);
// Should match with the inserted channels
final updatedChannels = await channelQueryDao.getChannels(
filter: filter,
paginationParams: pagination,
);
expect(updatedChannels.length, limit);
expect(updatedChannels.first.id, 'testId24');
expect(updatedChannels.first.cid, 'testCid24');
},
);
test('should return sorted channels using member count', () async {
int sortComparator(ChannelModel a, ChannelModel b) =>
b.memberCount.compareTo(a.memberCount);
// Inserting test data for get channels
final insertedChannels = await _insertTestDataForGetChannel(filter);
insertedChannels.sort(sortComparator);
// Should match with the inserted channels
final updatedChannels = await channelQueryDao.getChannels(
filter: filter,
sort: [SortOption('member_count', comparator: sortComparator)],
);
expect(updatedChannels.length, insertedChannels.length);
for (var i = 0; i < updatedChannels.length; i++) {
final updatedChannel = updatedChannels[i];
final insertedChannel = insertedChannels[i];
// Should match all the basic details
expect(updatedChannel.id, insertedChannel.id);
expect(updatedChannel.type, insertedChannel.type);
expect(updatedChannel.cid, insertedChannel.cid);
expect(updatedChannel.memberCount, insertedChannel.memberCount);
// Should match createdAt date
expect(
updatedChannel.createdAt,
isSameDateAs(insertedChannel.createdAt),
);
// Should match lastMessageAt date
expect(
updatedChannel.lastMessageAt,
isSameDateAs(insertedChannel.lastMessageAt),
);
}
});
test('should throw if comparator is not provided in sort list', () {
expect(
() => channelQueryDao.getChannels(
sort: [const SortOption('test_custom_field')],
),
throwsArgumentError,
);
});
test('should return sorted channels using custom field', () async {
int sortComparator(ChannelModel a, ChannelModel b) {
final aData = a.extraData['test_custom_field'] as int;
final bData = b.extraData['test_custom_field'] as int;
return bData.compareTo(aData);
}
// Inserting test data for get channels
final insertedChannels = await _insertTestDataForGetChannel(filter);
insertedChannels.sort(sortComparator);
// Should match with the inserted channels
final updatedChannels = await channelQueryDao.getChannels(
filter: filter,
sort: [SortOption('test_custom_field', comparator: sortComparator)],
);
expect(updatedChannels.length, insertedChannels.length);
for (var i = 0; i < updatedChannels.length; i++) {
final updatedChannel = updatedChannels[i];
final insertedChannel = insertedChannels[i];
// Should match all the basic details
expect(updatedChannel.id, insertedChannel.id);
expect(updatedChannel.type, insertedChannel.type);
expect(updatedChannel.cid, insertedChannel.cid);
expect(updatedChannel.memberCount, insertedChannel.memberCount);
// Should match createdAt date
expect(
updatedChannel.createdAt,
isSameDateAs(insertedChannel.createdAt),
);
// Should match lastMessageAt date
expect(
updatedChannel.lastMessageAt,
isSameDateAs(insertedChannel.lastMessageAt),
);
}
});
});
tearDown(() async {
await database.disconnect();
});
}

Some files were not shown because too many files have changed in this diff Show More