From 9ab33b5c7e5254d003257898fd59db77ac445b11 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 1 Apr 2021 15:33:05 +0200 Subject: [PATCH 1/4] removed gitter --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index b0a88432..150803e8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ ![](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/images/sdk_hero_v4.png) -[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ![CI](https://github.com/GetStream/stream-chat-flutter/workflows/stream_flutter_workflow/badge.svg?branch=master) [![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) From e749b881c97b92e6cb83a20cb64b15d8608a666b Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 1 Apr 2021 15:33:32 +0200 Subject: [PATCH 2/4] Update README.md --- packages/stream_chat/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_chat/README.md b/packages/stream_chat/README.md index adeb6bb5..1ee52ec5 100644 --- a/packages/stream_chat/README.md +++ b/packages/stream_chat/README.md @@ -4,7 +4,6 @@ [![Pub](https://img.shields.io/pub/v/stream_chat.svg)](https://pub.dartlang.org/packages/stream_chat) ![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) -[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ![CI](https://github.com/GetStream/stream-chat-flutter/workflows/stream_flutter_workflow/badge.svg?branch=master) **Quick Links** From aeb6da8d38612a94dbab9bf06f7baf9eae551794 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 1 Apr 2021 15:34:43 +0200 Subject: [PATCH 3/4] remove gitter --- packages/stream_chat_flutter/README.md | 1 - packages/stream_chat_flutter_core/README.md | 1 - packages/stream_chat_persistence/README.md | 1 - 3 files changed, 3 deletions(-) diff --git a/packages/stream_chat_flutter/README.md b/packages/stream_chat_flutter/README.md index c20f7e90..44f7582b 100644 --- a/packages/stream_chat_flutter/README.md +++ b/packages/stream_chat_flutter/README.md @@ -7,7 +7,6 @@ [![Pub](https://img.shields.io/pub/v/stream_chat_flutter.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) ![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) -[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ![CI](https://github.com/GetStream/stream-chat-flutter/workflows/stream_flutter_workflow/badge.svg?branch=master) **Quick Links** diff --git a/packages/stream_chat_flutter_core/README.md b/packages/stream_chat_flutter_core/README.md index 1fcf000b..9d39ab76 100644 --- a/packages/stream_chat_flutter_core/README.md +++ b/packages/stream_chat_flutter_core/README.md @@ -5,7 +5,6 @@ [![Pub](https://img.shields.io/pub/v/stream_chat_flutter_core.svg)](https://pub.dartlang.org/packages/stream_chat_flutter_core) ![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) -[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ![CI](https://github.com/GetStream/stream-chat-flutter/workflows/stream_flutter_workflow/badge.svg?branch=master) **Quick Links** diff --git a/packages/stream_chat_persistence/README.md b/packages/stream_chat_persistence/README.md index c79f3fe2..7f94eda1 100644 --- a/packages/stream_chat_persistence/README.md +++ b/packages/stream_chat_persistence/README.md @@ -5,7 +5,6 @@ [![Pub](https://img.shields.io/pub/v/stream_chat_persistence.svg)](https://pub.dartlang.org/packages/stream_chat_persistence) ![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) -[![Gitter](https://badges.gitter.im/GetStream/stream_chat_persistence.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ![CI](https://github.com/GetStream/stream-chat-flutter/workflows/stream_flutter_workflow/badge.svg?branch=master) From 87ca818a9a80df3a6f59ef6a261b6f76909aaff5 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 7 Apr 2021 12:24:29 +0200 Subject: [PATCH 4/4] chore: 1.5.1 (#387) * lint(persistence): Apply team linter Signed-off-by: Sahil Kumar * style: flutter format Signed-off-by: Sahil Kumar * test(persistence): Add tests for channel dao Signed-off-by: Sahil Kumar * refactor(persistence): remove dummy comments Signed-off-by: Sahil Kumar * test(persistence): Add test for channel query dao Signed-off-by: Sahil Kumar * test(persistence): add tests for connection event dao Signed-off-by: Sahil Kumar * test(persistence): add test for member dao Signed-off-by: Sahil Kumar * test(persistence): add tests for message dao Signed-off-by: Sahil Kumar * test(persistence): add test for pinned message dao Signed-off-by: Sahil Kumar * test(persistence): add test for reaction dao Signed-off-by: Sahil Kumar * test(persistence): add test for read dao Signed-off-by: Sahil Kumar * test(persistence): minor improvement in update function tests Signed-off-by: Sahil Kumar * test(persistence): add tests for user dao Signed-off-by: Sahil Kumar * test(persistence): add test for list converter Signed-off-by: Sahil Kumar * test(persistence): add test for map converter Signed-off-by: Sahil Kumar * test(persistence): add tests for message sending status converter Signed-off-by: Sahil Kumar * test(persistence): add tests for channel mapper Signed-off-by: Sahil Kumar * test(persistence): add test for event mapper Signed-off-by: Sahil Kumar * test(persistence): add tests for member mapper Signed-off-by: Sahil Kumar * remove useless dependencies * test(persistence): add tests for message mapper Signed-off-by: Sahil Kumar * test(persistence): add tests for pinned message mapper Signed-off-by: Sahil Kumar * test(persistence): add tests for reaction mapper Signed-off-by: Sahil Kumar * test(persistence): add tests for read mapper Signed-off-by: Sahil Kumar * test(persistence): add tests for user mapper Signed-off-by: Sahil Kumar * refactor(persistence): remove redundant UsersTable getter from ConnectionEventDao Signed-off-by: Sahil Kumar * test(persistence): add tests for stream chat persistence client Signed-off-by: Sahil Kumar * test(persistence): add tests moor chat database Signed-off-by: Sahil Kumar * test(persistence): add moor schemas for future migration tests Signed-off-by: Sahil Kumar * test(persistence): add clear queryCache test in channel query dao Signed-off-by: Sahil Kumar * test(persistence): add getThreadMessagesByParentId along with pagination test in message, pinned message dao. Signed-off-by: Sahil Kumar * test(persistence): add getMessagesByCid along with pagination test in message, pinned message dao. Signed-off-by: Sahil Kumar * 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 * test(persistence): add extra checks in getMessagesByCid along with pagination test in message, pinned message dao. Signed-off-by: Sahil Kumar * test(persistence): add return channel with pagination applies test in channel query dao Signed-off-by: Sahil Kumar * test(persistence): improve message mapper, pinned message mapper test Signed-off-by: Sahil Kumar * test(persistence): add client constructor test in stream chat persistence client Signed-off-by: Sahil Kumar * test(persistence): add coverage:ignore-file for entities and db/shared directory Signed-off-by: Sahil Kumar * skip pub publish --dry-run for develop branch Signed-off-by: Sahil Kumar * add remove from coverage script Signed-off-by: Sahil Kumar * fix Signed-off-by: Sahil Kumar * fix Signed-off-by: Sahil Kumar * fix Signed-off-by: Sahil Kumar * fix Signed-off-by: Sahil Kumar * fix locale * fix message input _parseExistingMessage * fix(UI-Kit): Show selected attachment file as file in message input widget Signed-off-by: Sahil Kumar * 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 Co-authored-by: Salvatore Giordano Co-authored-by: Sahil Kumar * test: add tests for stream chat flutter core (#354) * test(core): add tests for channels bloc Signed-off-by: Sahil Kumar * test(core): refactor some tests due to changes in channels bloc Signed-off-by: Sahil Kumar * test(core): add tests for channel list core Signed-off-by: Sahil Kumar * test(core): add tests for users bloc, minor improvements Signed-off-by: Sahil Kumar * test(core): add tests for user list core, minor improvements Signed-off-by: Sahil Kumar * test(core): add tests for message search bloc, refactor Signed-off-by: Sahil Kumar * test(core): add tests for message search list core, refactor Signed-off-by: Sahil Kumar * test(core): add tests for lazy load scroll view Signed-off-by: Sahil Kumar * test(core): add tests for stream chat core Signed-off-by: Sahil Kumar * test(core): add tests for message list core Signed-off-by: Sahil Kumar * test(core): add tests for stream channel Signed-off-by: Sahil Kumar * Fix merge conflicts Signed-off-by: Sahil Kumar * flutter format Signed-off-by: Sahil Kumar * fixes Signed-off-by: Sahil Kumar * update workflow Signed-off-by: Sahil Kumar * chore(core): fix analyzer issues Signed-off-by: Sahil Kumar * chore(ui-kit): fix analyzer issues Signed-off-by: Sahil Kumar * refactor(ui-kit): update giphy interaction (#361) * refactor(ui-kit): update giphy interaction Signed-off-by: Sahil Kumar * fix overflow Co-authored-by: Salvatore Giordano * fix: Fixed sendingbutton.outside case (#355) * fix: Fixed sendingbutton.outside case * fix: Fixed all actionbutton cases * fix review Co-authored-by: Salvatore Giordano * fix(ui-kit): fix accessibility text size scaling in message widget Signed-off-by: Sahil Kumar * fix(ui-kit): fix message widget long username overflow Signed-off-by: Sahil Kumar * fix(ui-kit): fix message input dm checkbox accessibility scaling issue Signed-off-by: Sahil Kumar * feat: exposed textbuilder callback (#367) * exposed textbuilder callback * exposed textbuilder callback * fixes Signed-off-by: Sahil Kumar * 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 * 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 * 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 Co-authored-by: Neevash Ramdial (Nash) Co-authored-by: Deven Joshi --- .github/workflows/scripts/coverage.sh | 6 + .github/workflows/stream_flutter_workflow.yml | 20 +- packages/stream_chat/CHANGELOG.md | 4 + packages/stream_chat/analysis_options.yaml | 8 +- packages/stream_chat/lib/src/api/channel.dart | 11 +- .../stream_chat/lib/src/api/requests.dart | 21 + .../stream_chat/lib/src/api/websocket.dart | 22 +- packages/stream_chat/lib/src/client.dart | 3 +- .../lib/src/db/chat_persistence_client.dart | 7 +- .../stream_chat/lib/src/models/event.dart | 40 + packages/stream_chat/lib/src/models/read.dart | 12 + packages/stream_chat/lib/src/models/user.dart | 24 + packages/stream_chat/lib/version.dart | 2 +- packages/stream_chat/pubspec.yaml | 4 +- .../test/src/api/web_socket_stub_test.dart | 9 - .../test/src/api/websocket_test.dart | 45 +- packages/stream_chat_flutter/CHANGELOG.md | 6 + .../lib/src/attachment/giphy_attachment.dart | 205 ++-- .../lib/src/channel_file_display_screen.dart | 2 +- .../lib/src/channel_list_view.dart | 15 +- .../lib/src/channel_media_display_screen.dart | 2 +- .../lib/src/channel_preview.dart | 4 +- .../lib/src/date_divider.dart | 6 +- .../lib/src/extension.dart | 5 + .../lib/src/full_screen_media.dart | 33 +- .../lib/src/image_footer.dart | 7 +- .../lib/src/image_header.dart | 61 +- .../lib/src/mention_tile.dart | 99 ++ .../lib/src/message_input.dart | 232 ++-- .../lib/src/message_list_view.dart | 8 +- .../lib/src/message_search_item.dart | 4 +- .../lib/src/message_search_list_view.dart | 8 +- .../lib/src/message_widget.dart | 37 +- .../lib/src/stream_chat.dart | 9 + .../lib/src/stream_chat_theme.dart | 8 + .../lib/src/user_list_view.dart | 12 +- .../lib/stream_chat_flutter.dart | 1 + packages/stream_chat_flutter/pubspec.yaml | 7 +- .../test/src/channel_preview_test.dart | 2 +- .../stream_chat_flutter_core/CHANGELOG.md | 5 + .../analysis_options.yaml | 145 +++ .../lib/src/channel_list_core.dart | 114 +- .../lib/src/channels_bloc.dart | 62 +- .../lib/src/lazy_load_scroll_view.dart | 60 +- .../lib/src/message_list_core.dart | 85 +- .../lib/src/message_search_bloc.dart | 82 +- .../lib/src/message_search_list_core.dart | 121 +- .../lib/src/stream_channel.dart | 58 +- .../lib/src/stream_chat_core.dart | 52 +- .../lib/src/typedef.dart | 4 +- .../lib/src/user_list_core.dart | 202 ++- .../lib/src/users_bloc.dart | 38 +- .../lib/stream_chat_flutter_core.dart | 13 +- .../stream_chat_flutter_core/pubspec.yaml | 10 +- .../test/channel_list_core_test.dart | 504 ++++++++ .../test/channels_bloc_test.dart | 847 +++++++++++++ .../test/lazy_load_scroll_view_test.dart | 294 +++++ .../test/matchers/channel_matcher.dart | 49 + .../get_message_response_matcher.dart | 55 + .../test/matchers/message_matcher.dart | 49 + .../test/matchers/users_matcher.dart | 47 + .../test/message_list_core_test.dart | 413 +++++++ .../test/message_search_bloc_test.dart | 344 ++++++ .../test/message_search_list_core_test.dart | 535 ++++++++ .../stream_chat_flutter_core/test/mocks.dart | 35 +- .../test/stream_channel_test.dart | 343 ++++++ .../test/stream_chat_core_test.dart | 378 ++++-- .../test/user_list_core_test.dart | 572 +++++++++ .../test/users_bloc_test.dart | 327 +++++ packages/stream_chat_persistence/CHANGELOG.md | 5 + .../lib/src/dao/channel_query_dao.dart | 17 +- .../lib/src/dao/connection_event_dao.dart | 3 +- .../lib/src/dao/connection_event_dao.g.dart | 1 - .../lib/src/dao/message_dao.dart | 64 +- .../lib/src/dao/pinned_message_dao.dart | 62 +- .../lib/src/dao/user_dao.dart | 6 + .../lib/src/db/moor_chat_database.dart | 32 +- .../lib/src/db/shared/native_db.dart | 80 +- .../lib/src/db/shared/unsupported_db.dart | 24 +- .../lib/src/db/shared/web_db.dart | 24 +- .../lib/src/entity/channel_queries.dart | 1 + .../lib/src/entity/channels.dart | 1 + .../lib/src/entity/connection_events.dart | 1 + .../lib/src/entity/members.dart | 1 + .../lib/src/entity/messages.dart | 1 + .../lib/src/entity/pinned_messages.dart | 1 + .../lib/src/entity/reactions.dart | 1 + .../lib/src/entity/reads.dart | 1 + .../lib/src/entity/users.dart | 1 + .../lib/src/mapper/channel_mapper.dart | 4 +- .../src/stream_chat_persistence_client.dart | 53 +- .../moor_schemas/moor_schema_v1.json | 798 ++++++++++++ .../moor_schemas/moor_schema_v2.json | 1089 +++++++++++++++++ packages/stream_chat_persistence/pubspec.yaml | 6 +- .../test/mock_chat_database.dart | 71 ++ .../src/converter/list_coverter_test.dart | 53 + .../src/converter/map_converter_test.dart | 64 + ...message_sending_status_converter_test.dart | 33 + .../test/src/dao/channel_dao_test.dart | 130 ++ .../test/src/dao/channel_query_dao_test.dart | 273 +++++ .../src/dao/connection_event_dao_test.dart | 114 ++ .../test/src/dao/member_dao_test.dart | 152 +++ .../test/src/dao/message_dao_test.dart | 362 ++++++ .../test/src/dao/pinned_message_dao_test.dart | 363 ++++++ .../test/src/dao/reaction_dao_test.dart | 188 +++ .../test/src/dao/read_dao_test.dart | 97 ++ .../test/src/dao/user_dao_test.dart | 77 ++ .../test/src/db/moor_chat_database_test.dart | 43 + .../test/src/mapper/channel_mapper_test.dart | 111 ++ .../test/src/mapper/event_mapper_test.dart | 23 + .../test/src/mapper/member_mapper_test.dart | 69 ++ .../test/src/mapper/message_mapper_test.dart | 184 +++ .../mapper/pinned_message_mapper_test.dart | 184 +++ .../test/src/mapper/reaction_mapper_test.dart | 52 + .../test/src/mapper/read_mapper_test.dart | 41 + .../test/src/mapper/user_mapper_test.dart | 55 + .../stream_chat_persistence_client_test.dart | 27 - .../test/src/utils/date_matcher.dart | 26 + .../stream_chat_persistence_client_test.dart | 540 ++++++++ 119 files changed, 11223 insertions(+), 1240 deletions(-) create mode 100755 .github/workflows/scripts/coverage.sh delete mode 100644 packages/stream_chat/test/src/api/web_socket_stub_test.dart create mode 100644 packages/stream_chat_flutter/lib/src/mention_tile.dart create mode 100644 packages/stream_chat_flutter_core/analysis_options.yaml create mode 100644 packages/stream_chat_flutter_core/test/channel_list_core_test.dart create mode 100644 packages/stream_chat_flutter_core/test/channels_bloc_test.dart create mode 100644 packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart create mode 100644 packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart create mode 100644 packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart create mode 100644 packages/stream_chat_flutter_core/test/matchers/message_matcher.dart create mode 100644 packages/stream_chat_flutter_core/test/matchers/users_matcher.dart create mode 100644 packages/stream_chat_flutter_core/test/message_list_core_test.dart create mode 100644 packages/stream_chat_flutter_core/test/message_search_bloc_test.dart create mode 100644 packages/stream_chat_flutter_core/test/message_search_list_core_test.dart create mode 100644 packages/stream_chat_flutter_core/test/stream_channel_test.dart create mode 100644 packages/stream_chat_flutter_core/test/user_list_core_test.dart create mode 100644 packages/stream_chat_flutter_core/test/users_bloc_test.dart create mode 100644 packages/stream_chat_persistence/moor_schemas/moor_schema_v1.json create mode 100644 packages/stream_chat_persistence/moor_schemas/moor_schema_v2.json create mode 100644 packages/stream_chat_persistence/test/mock_chat_database.dart create mode 100644 packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart create mode 100644 packages/stream_chat_persistence/test/src/converter/map_converter_test.dart create mode 100644 packages/stream_chat_persistence/test/src/converter/message_sending_status_converter_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/member_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/message_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/read_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/dao/user_dao_test.dart create mode 100644 packages/stream_chat_persistence/test/src/db/moor_chat_database_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart create mode 100644 packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart delete mode 100644 packages/stream_chat_persistence/test/src/stream_chat_persistence_client_test.dart create mode 100644 packages/stream_chat_persistence/test/src/utils/date_matcher.dart create mode 100644 packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart diff --git a/.github/workflows/scripts/coverage.sh b/.github/workflows/scripts/coverage.sh new file mode 100755 index 00000000..edff4ae4 --- /dev/null +++ b/.github/workflows/scripts/coverage.sh @@ -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$' \ No newline at end of file diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 52a504fe..aa621854 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -8,6 +8,7 @@ on: push: branches: - master + - develop paths-ignore: - 'docs/**' @@ -32,6 +33,7 @@ jobs: melos exec -c 3 --ignore="*example*" -- \ tuneup check - name: 'Pub Check' + if: github.ref == 'refs/heads/master' run: | melos exec -c 1 --no-private --ignore="*example*" -- \ pub publish --dry-run @@ -57,7 +59,7 @@ jobs: test: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - uses: actions/checkout@v2 with: @@ -68,31 +70,35 @@ jobs: run: | ./.github/workflows/scripts/install-tools.sh flutter pub global activate coverage + flutter pub global activate remove_from_coverage - name: 'Bootstrap Workspace' run: melos bootstrap - name: 'Dart Test' run: | cd packages/stream_chat 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' run: | melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ flutter test --coverage - 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/very_good_coverage@v1.1.1 with: - path: packages/stream_chat/lcov.info - min_coverage: 50 + path: packages/stream_chat/coverage/lcov.info + min_coverage: 40 - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_persistence/coverage/lcov.info - min_coverage: 0.2 + min_coverage: 95 - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter_core/coverage/lcov.info - min_coverage: 4.5 + min_coverage: 90 - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter/coverage/lcov.info diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 8cf0fad1..87000dc4 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.5.1 + +- Minor fixes and improvements + ## 1.5.0 - Minor fixes and improvements diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml index 26001160..ddc691d0 100644 --- a/packages/stream_chat/analysis_options.yaml +++ b/packages/stream_chat/analysis_options.yaml @@ -1,11 +1,11 @@ analyzer: - exclude: + exclude: - lib/**/*.g.dart - lib/**/*.freezed.dart - example/* - test/* -linter: - rules: +linter: + rules: - always_use_package_imports - avoid_empty_else - avoid_relative_lib_imports @@ -14,7 +14,6 @@ linter: - cancel_subscriptions - close_sinks - control_flow_in_finally - - diagnostic_describe_all_properties - empty_statements - hash_and_equals - invariant_booleans @@ -33,7 +32,6 @@ linter: - use_key_in_widget_constructors - valid_regexps - always_declare_return_types - - always_put_required_named_parameters_first - always_require_non_null_named_parameters - annotate_overrides - avoid_bool_literals_in_conditional_expressions diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 1701fed8..b424f6f1 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -146,7 +146,7 @@ class Channel { /// Channel extra data Map get extraData => - state?._channelState?.channel?.extraData; + state?._channelState?.channel?.extraData ?? _extraData; /// Channel extra data as a stream Stream> get extraDataStream => @@ -1210,9 +1210,10 @@ class ChannelClientState { this._channel, ChannelState channelState, //ignore: unnecessary_parenthesis - ) : _debouncedUpdatePersistenceChannelState = ((ChannelState state) { - _channel?._client?.chatPersistenceClient?.updateChannelState(state); - }).debounced(const Duration(seconds: 1)) { + ) : _debouncedUpdatePersistenceChannelState = ((ChannelState state) => + _channel?._client?.chatPersistenceClient + ?.updateChannelState(state)) + .debounced(const Duration(seconds: 1)) { retryQueue = RetryQueue( channel: _channel, logger: Logger('RETRY QUEUE ${_channel.cid}'), @@ -1597,7 +1598,7 @@ class ChannelClientState { bool _countMessageAsUnread(Message message) { 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, orElse: () => null, ) != diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/api/requests.dart index 764d249a..51151fca 100644 --- a/packages/stream_chat/lib/src/api/requests.dart +++ b/packages/stream_chat/lib/src/api/requests.dart @@ -104,4 +104,25 @@ class PaginationParams { lessThan: lessThan ?? this.lessThan, 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; } diff --git a/packages/stream_chat/lib/src/api/websocket.dart b/packages/stream_chat/lib/src/api/websocket.dart index ea2bb291..0f78b1bf 100644 --- a/packages/stream_chat/lib/src/api/websocket.dart +++ b/packages/stream_chat/lib/src/api/websocket.dart @@ -6,9 +6,6 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.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/user.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -36,7 +33,7 @@ class WebSocket { this.connectPayload, this.handler, this.logger, - this.connectFunc = connectWebSocket, + this.connectFunc, this.reconnectionMonitorInterval = 1, this.healthCheckInterval = 20, this.reconnectionMonitorTimeout = 40, @@ -121,9 +118,9 @@ class WebSocket { WebSocketChannel _channel; Timer _healthCheck, _reconnectionMonitor; DateTime _lastEventAt; - bool _manuallyDisconnected = false, - _connecting = false, - _reconnecting = false; + bool _manuallyDisconnected = false; + bool _connecting = false; + bool _reconnecting = false; Event _decodeEvent(String source) => Event.fromJson(json.decode(source)); @@ -143,7 +140,8 @@ class WebSocket { logger.info('connecting to $_path'); - _channel = connectFunc(_path); + _channel = + connectFunc?.call(_path) ?? WebSocketChannel.connect(Uri.parse(_path)); _channel.stream.listen( (data) { final jsonData = json.decode(data); @@ -177,6 +175,10 @@ class WebSocket { } void _onData(data) { + if (_manuallyDisconnected) { + return; + } + final event = _decodeEvent(data); logger.info('received new event: $data'); @@ -302,6 +304,10 @@ class WebSocket { /// Disconnects the WS and releases eventual resources Future disconnect() async { + _connecting = false; + if (!_connectionCompleter.isCompleted) { + _connectionCompleter.complete(); + } if (_manuallyDisconnected) { return; } diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 11619b35..8b9d3a75 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -738,7 +738,8 @@ class StreamChatClient { await _chatPersistenceClient?.updateChannelQueries( filter, channels.map((c) => c.channel.cid).toList(), - paginationParams?.offset == null || paginationParams.offset == 0, + clearQueryCache: + paginationParams?.offset == null || paginationParams.offset == 0, ); state.channels = updateData.key; diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index d90872a2..d15772df 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -100,10 +100,9 @@ abstract class ChatPersistenceClient { /// the list of matching rows will be deleted Future updateChannelQueries( Map filter, - List cids, - // ignore: avoid_positional_boolean_parameters - bool clearQueryCache, - ); + List cids, { + bool clearQueryCache = false, + }); /// Remove a message by [messageId] Future deleteMessageById(String messageId) => diff --git a/packages/stream_chat/lib/src/models/event.dart b/packages/stream_chat/lib/src/models/event.dart index b1140ebc..28cf1b38 100644 --- a/packages/stream_chat/lib/src/models/event.dart +++ b/packages/stream_chat/lib/src/models/event.dart @@ -121,6 +121,46 @@ class Event { _$EventToJson(this), 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 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 diff --git a/packages/stream_chat/lib/src/models/read.dart b/packages/stream_chat/lib/src/models/read.dart index 2be639d5..fc8ef1bc 100644 --- a/packages/stream_chat/lib/src/models/read.dart +++ b/packages/stream_chat/lib/src/models/read.dart @@ -27,4 +27,16 @@ class Read { /// Serialize to json Map 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, + ); } diff --git a/packages/stream_chat/lib/src/models/user.dart b/packages/stream_chat/lib/src/models/user.dart index 618c1986..3b9f9add 100644 --- a/packages/stream_chat/lib/src/models/user.dart +++ b/packages/stream_chat/lib/src/models/user.dart @@ -100,4 +100,28 @@ class User { /// Serialize to json Map toJson() => 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 extraData, + bool banned, + List 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, + ); } diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index c593eede..4d12add5 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -3,4 +3,4 @@ import 'package:stream_chat/src/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '1.5.0'; +const PACKAGE_VERSION = '1.5.1'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 81aef23e..24298ae1 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ 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 issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -20,7 +20,7 @@ dependencies: mime: ^0.9.7 rxdart: ^0.25.0 uuid: ^2.2.2 - web_socket_channel: ^1.1.0 + web_socket_channel: ^1.2.0 dev_dependencies: build_runner: ^1.10.0 diff --git a/packages/stream_chat/test/src/api/web_socket_stub_test.dart b/packages/stream_chat/test/src/api/web_socket_stub_test.dart deleted file mode 100644 index e60706dc..00000000 --- a/packages/stream_chat/test/src/api/web_socket_stub_test.dart +++ /dev/null @@ -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())); - }); -} diff --git a/packages/stream_chat/test/src/api/websocket_test.dart b/packages/stream_chat/test/src/api/websocket_test.dart index d9486477..238cc850 100644 --- a/packages/stream_chat/test/src/api/websocket_test.dart +++ b/packages/stream_chat/test/src/api/websocket_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:test/test.dart'; import 'package:logging/logging.dart'; import 'package:mockito/mockito.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/user.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; class Functions { @@ -47,10 +47,9 @@ void main() { final mockWSChannel = MockWSChannel(); - final StreamController streamController = - StreamController.broadcast(); + final streamController = StreamController.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'; when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); @@ -60,7 +59,7 @@ void main() { }); final timer = Timer.periodic( - Duration(milliseconds: 100), + const Duration(milliseconds: 100), (_) => streamController.sink.add('{}'), ); @@ -160,6 +159,42 @@ void main() { 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 streamController = + StreamController.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 { final handleFunc = MockFunctions().handleFunc; diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ff336e8d..7b78e11c 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.5.2 + +- Fix accessibility text size overflows +- Updated Giphy attachment ui +- Minor fixes and improvements + ## 1.5.1 - Fixed unread count not updating while the chat is open diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 5bb810b4..8b8bb4c1 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -59,138 +59,57 @@ class GiphyAttachment extends AttachmentWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Stack( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () => onAttachmentTap ?? _onImageTap(context), - child: ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(8), - topRight: Radius.circular(8), - ), - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - imageUrl: imageUrl, - errorWidget: (context, url, error) { - return AttachmentError(size: size); - }, - fit: BoxFit.cover, - ), - ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + StreamSvgIcon.giphyIcon(), + SizedBox(width: 8), + Text( + 'Giphy', + style: TextStyle(fontWeight: FontWeight.bold), ), - ), - 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, - ), - ), - ], + 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, ), ), - ), - ), - ], + ], + ), ), - 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', - }); - }, - ), + Padding( + padding: const EdgeInsets.all(2.0), + child: GestureDetector( + onTap: () => onAttachmentTap ?? _onImageTap(context), + child: CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), ), - 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', - }); - }, - ), - ), - ], - ), + ); + }, + imageUrl: imageUrl, + errorWidget: (context, url, error) { + return AttachmentError(size: size); + }, + fit: BoxFit.cover, ), ), - SizedBox( - height: 4.0, ), Container( color: StreamChatTheme.of(context) @@ -236,6 +155,38 @@ class GiphyAttachment extends AttachmentWidget { .withOpacity(0.2), 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( child: Container( height: 50, @@ -261,9 +212,7 @@ class GiphyAttachment extends AttachmentWidget { ], ), ), - SizedBox( - height: 4.0, - ), + SizedBox(height: 4.0), Align( alignment: Alignment.centerRight, child: Padding( diff --git a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart index 2813f300..083b238b 100644 --- a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart @@ -143,7 +143,7 @@ class _ChannelFileDisplayScreenState extends State { } return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.loadMore( + onEndOfPage: () => messageSearchBloc.search( filter: { 'cid': { r'$in': [StreamChannel.of(context).channel.cid] diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index 0396bffe..9751ad6d 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -628,13 +628,14 @@ class _ChannelListViewState extends State { ), ); } - return Container( - height: 100, - padding: EdgeInsets.all(32), - child: Center( - child: snapshot.data ? CircularProgressIndicator() : Container(), - ), - ); + return snapshot.data + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: const CircularProgressIndicator(), + ), + ) + : Offstage(); }); } diff --git a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart index 251143ed..7b34e449 100644 --- a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart @@ -166,7 +166,7 @@ class _ChannelMediaDisplayScreenState extends State { } return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.loadMore( + onEndOfPage: () => messageSearchBloc.search( filter: { 'cid': { r'$in': [StreamChannel.of(context).channel.cid] diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 9ba34f3a..3243988b 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -170,14 +170,14 @@ class ChannelPreview extends StatelessWidget { if (lastMessageAt.millisecondsSinceEpoch >= startOfDay.millisecondsSinceEpoch) { - stringDate = Jiffy(lastMessageAt.toLocal()).format('HH:mm'); + stringDate = Jiffy(lastMessageAt.toLocal()).jm; } else if (lastMessageAt.millisecondsSinceEpoch >= startOfDay.subtract(Duration(days: 1)).millisecondsSinceEpoch) { stringDate = 'Yesterday'; } else if (startOfDay.difference(lastMessageAt).inDays < 7) { stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; } else { - stringDate = Jiffy(lastMessageAt.toLocal()).format('dd/MM/yyyy'); + stringDate = Jiffy(lastMessageAt.toLocal()).yMd; } return Text( diff --git a/packages/stream_chat_flutter/lib/src/date_divider.dart b/packages/stream_chat_flutter/lib/src/date_divider.dart index c7cfe296..20b8151d 100644 --- a/packages/stream_chat_flutter/lib/src/date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/date_divider.dart @@ -28,14 +28,14 @@ class DateDivider extends StatelessWidget { now.subtract(Duration(days: 7)), Units.DAY, )) { - dayInfo = createdAt.format('EEEE'); + dayInfo = createdAt.EEEE; } else if (Jiffy(createdAt).isAfter( Jiffy(now).subtract(years: 1), Units.DAY, )) { - dayInfo = createdAt.format('MMMM d'); + dayInfo = createdAt.MMMd; } else { - dayInfo = createdAt.format('MMMM d'); + dayInfo = createdAt.MMMd; } if (uppercase) dayInfo = dayInfo.toUpperCase(); diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 65b1b100..daa8a69c 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -97,3 +97,8 @@ extension InputDecorationX on InputDecoration { ); } } + +extension BuildContextX on BuildContext { + double get textScaleFactor => + MediaQuery.maybeOf(this)?.textScaleFactor ?? 1.0; +} diff --git a/packages/stream_chat_flutter/lib/src/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/full_screen_media.dart index 88c20a51..aa35ed15 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -185,21 +185,22 @@ class _FullScreenMediaState extends State widget.message, StreamChannel.of(context).channel); }, ), - ImageFooter( - currentPage: _currentPage, - totalPages: widget.mediaAttachments.length, - mediaAttachments: widget.mediaAttachments, - message: widget.message, - mediaSelectedCallBack: (val) { - setState(() { - _currentPage = val; - _pageController.animateToPage(val, - duration: Duration(milliseconds: 300), - curve: Curves.easeInOut); - Navigator.pop(context); - }); - }, - ), + if (widget.message.type != 'ephemeral') + ImageFooter( + currentPage: _currentPage, + totalPages: widget.mediaAttachments.length, + mediaAttachments: widget.mediaAttachments, + message: widget.message, + mediaSelectedCallBack: (val) { + setState(() { + _currentPage = val; + _pageController.animateToPage(val, + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut); + Navigator.pop(context); + }); + }, + ), ], ), ), @@ -220,7 +221,7 @@ class _FullScreenMediaState extends State 24) { return 'yesterday'; } else { - return 'on ${Jiffy(dateTime).format("MMM do")}'; + return 'on ${Jiffy(dateTime).MMMd}'; } } diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart index 1d0d7e78..6833a52f 100644 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -78,6 +78,7 @@ class _ImageFooterState extends State { @override Widget build(BuildContext context) { + final showShareButton = !kIsWeb; return SizedBox.fromSize( size: Size( MediaQuery.of(context).size.width, @@ -91,8 +92,10 @@ class _ImageFooterState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - kIsWeb - ? SizedBox() + !showShareButton + ? Container( + width: 48, + ) : IconButton( icon: StreamSvgIcon.iconShare( size: 24.0, diff --git a/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/image_header.dart index bf4ec474..023c8eaa 100644 --- a/packages/stream_chat_flutter/lib/src/image_header.dart +++ b/packages/stream_chat_flutter/lib/src/image_header.dart @@ -63,38 +63,43 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { backgroundColor: StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, actions: [ - IconButton( - icon: StreamSvgIcon.iconMenuPoint( - color: StreamChatTheme.of(context).colorTheme.black, + if (message.type != 'ephemeral') + IconButton( + icon: StreamSvgIcon.iconMenuPoint( + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () { + _showMessageActionModalBottomSheet(context); + }, ), - onPressed: () { - _showMessageActionModalBottomSheet(context); - }, - ), ], centerTitle: true, - title: InkWell( - onTap: onTitleTap, - child: Container( - height: preferredSize.height, - width: preferredSize.width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - userName, - style: StreamChatTheme.of(context).textTheme.headlineBold, + title: message.type != 'ephemeral' + ? InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + width: preferredSize.width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userName, + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + Text( + sentAt, + style: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle, + ), + ], + ), ), - Text( - sentAt, - style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, - ), - ], - ), - ), - ), + ) + : SizedBox(), ); } diff --git a/packages/stream_chat_flutter/lib/src/mention_tile.dart b/packages/stream_chat_flutter/lib/src/mention_tile.dart new file mode 100644 index 00000000..7d8b0ada --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/mention_tile.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 4a18bf02..385dc41f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -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/stream_chat_theme.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_core/stream_chat_flutter_core.dart'; import 'package:substring_highlight/substring_highlight.dart'; @@ -32,6 +31,13 @@ typedef AttachmentThumbnailBuilder = Widget Function( Attachment, ); +/// Builder function for building a mention tile +/// Use [MentionTile] for the default implementation +typedef MentionTileBuilder = Widget Function( + BuildContext context, + Member member, +); + enum ActionsLocation { left, right, @@ -125,6 +131,7 @@ class MessageInput extends StatefulWidget { this.idleSendButton, this.activeSendButton, this.showCommandsButton = true, + this.mentionsTileBuilder, }) : super(key: key); /// Message to edit @@ -191,6 +198,9 @@ class MessageInput extends StatefulWidget { /// Send button widget in an active state final Widget activeSendButton; + /// Customize the tile for the mentions overlay + final MentionTileBuilder mentionsTileBuilder; + @override MessageInputState createState() => MessageInputState(); @@ -319,7 +329,11 @@ class MessageInputState extends State { ), if (widget.parentMessage != null && !widget.hideSendAsDm) Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.only( + right: 12.0, + left: 12.0, + bottom: 12.0, + ), child: _buildDmCheckbox(), ), _buildFilePickerSection(), @@ -354,68 +368,68 @@ class MessageInputState extends State { } Widget _buildDmCheckbox() { - return Container( - height: 36, - padding: const EdgeInsets.only( - left: 12, - bottom: 12, - top: 8, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - height: 16, - width: 16, - foregroundDecoration: BoxDecoration( - border: _sendAsDm - ? null - : Border.all( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), - width: 2, - ), + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + width: 2, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Center( + child: Material( borderRadius: BorderRadius.circular(3), - ), - child: Center( - child: Material( - borderRadius: BorderRadius.circular(3), - color: _sendAsDm - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context).colorTheme.white, - child: InkWell( - onTap: () { - setState(() { - _sendAsDm = !_sendAsDm; - }); - }, - child: AnimatedCrossFade( - duration: Duration(milliseconds: 300), - reverseDuration: Duration(milliseconds: 300), - crossFadeState: _sendAsDm - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: StreamSvgIcon.check( - size: 16.0, - color: StreamChatTheme.of(context).colorTheme.white, - ), - secondChild: SizedBox( - height: 16, - width: 16, - ), + color: _sendAsDm + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context).colorTheme.white, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: StreamSvgIcon.check( + size: 16.0, + color: StreamChatTheme.of(context).colorTheme.white, + ), + secondChild: SizedBox( + height: 16, + width: 16, ), ), ), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Also send as direct message'), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + 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 { ? pi : 0, child: StreamSvgIcon.emptyCircleLeft( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context) + .messageInputTheme + .expandButtonColor, ), ), padding: const EdgeInsets.all(0), @@ -491,10 +507,17 @@ class MessageInputState extends State { Expanded _buildTextInput(BuildContext 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( child: Center( child: Container( clipBehavior: Clip.antiAlias, + margin: margin, decoration: BoxDecoration( borderRadius: theme.messageInputTheme.borderRadius, gradient: _focusNode.hasFocus @@ -656,6 +679,7 @@ class MessageInputState extends State { Timer _debounce; String _previousValue; + void _onChanged(BuildContext context, String s) { if (s == _previousValue) { return; @@ -1113,6 +1137,7 @@ class MessageInputState extends State { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); + return; } } @@ -1301,80 +1326,9 @@ class MessageInputState extends State { _mentionsOverlay?.remove(); _mentionsOverlay = null; }, - child: Container( - height: 56.0, - child: Row( - 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, - ), - ), - ], - ), - ), + child: widget.mentionsTileBuilder != null + ? widget.mentionsTileBuilder(context, m) + : MentionTile(m), ), ); }, @@ -1962,12 +1916,6 @@ class MessageInputState extends State { final extraDataMap = {}; - if (mimeType?.type == 'video' || mimeType?.type == 'image') { - attachmentType = mimeType.type; - } else { - attachmentType = 'file'; - } - if (mimeType?.subtype != null) { extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); } @@ -1982,8 +1930,6 @@ class MessageInputState extends State { extraData: extraDataMap.isNotEmpty ? extraDataMap : null, ); - _attachments[attachment.id] = attachment; - if (file.size > _kMaxAttachmentSize) { if (attachmentType == 'Video') { final mediaInfo = await VideoService.compressVideo(file.path); @@ -1992,7 +1938,6 @@ class MessageInputState extends State { _showErrorAlert( '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; } file = AttachmentFile( @@ -2005,9 +1950,12 @@ class MessageInputState extends State { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); + return; } } + _attachments[attachment.id] = attachment; + setState(() { _attachments.update(attachment.id, (it) { return it.copyWith( @@ -2233,7 +2181,7 @@ class MessageInputState extends State { void _parseExistingMessage(Message message) { textEditingController.text = message.text; _messageIsPresent = true; - for (final attachment in message.attachments) { + for (final attachment in message?.attachments) { _attachments[attachment.id] = attachment.copyWith( uploadState: attachment.uploadState ?? UploadState.success(), ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 5b1c3a99..9af5bc4a 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -141,6 +141,7 @@ class MessageListView extends StatefulWidget { this.onMessageTap, this.onSystemMessageTap, this.onAttachmentTap, + this.textBuilder, }) : super(key: key); /// Function used to build a custom message widget @@ -230,9 +231,12 @@ class MessageListView extends StatefulWidget { /// Called when system message is tapped final OnMessageTap onSystemMessageTap; - // Customize onTap on attachment + /// Customize onTap on attachment final void Function(Message message, Attachment attachment) onAttachmentTap; + /// Customize the MessageWidget textBuilder + final void Function(BuildContext context, Message message) textBuilder; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -839,6 +843,7 @@ class _MessageListViewState extends State { } FocusScope.of(context).unfocus(); }, + textBuilder: widget.textBuilder, ); } @@ -1015,6 +1020,7 @@ class _MessageListViewState extends State { FocusScope.of(context).unfocus(); }, onAttachmentTap: widget.onAttachmentTap, + textBuilder: widget.textBuilder, ); if (!message.isDeleted && diff --git a/packages/stream_chat_flutter/lib/src/message_search_item.dart b/packages/stream_chat_flutter/lib/src/message_search_item.dart index 335f7ff4..4cf7b5ed 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_item.dart @@ -84,9 +84,9 @@ class MessageSearchItem extends StatelessWidget { if (now.year != createdAt.year || now.month != createdAt.month || now.day != createdAt.day) { - stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy'); + stringDate = Jiffy(createdAt.toLocal()).yMd; } else { - stringDate = Jiffy(createdAt.toLocal()).format('HH:mm'); + stringDate = Jiffy(createdAt.toLocal()).jm; } return Text( diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 316e27b3..6431a311 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -262,17 +262,13 @@ class _MessageSearchListViewState extends State { ); if (widget.pullToRefresh) { child = RefreshIndicator( - onRefresh: () async { - _messageSearchListController.loadData(); - }, + onRefresh: () => _messageSearchListController.loadData(), child: child, ); } child = LazyLoadScrollView( - onEndOfPage: () async { - return _messageSearchListController.paginateData(); - }, + onEndOfPage: () => _messageSearchListController.paginateData(), child: child, ); diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index a0c1201a..af6ea1ec 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -585,7 +585,8 @@ class _MessageWidgetState extends State ), ], ), - if (showBottomRow) SizedBox(height: 20.0), + if (showBottomRow) + SizedBox(height: context.textScaleFactor * 18.0), ], ), if (showBottomRow) @@ -682,6 +683,8 @@ class _MessageWidgetState extends State } }; + const usernameKey = Key('username'); + children.addAll([ if (showInChannel || showThreadReplyIndicator) ...[ if (showThreadParticipants) @@ -697,7 +700,10 @@ class _MessageWidgetState extends State if (showUsername) Text( widget.message.user.name, + maxLines: 1, + key: usernameKey, style: widget.messageTheme.messageAuthor, + overflow: TextOverflow.ellipsis, ), if (showTimeStamp) Text( @@ -710,18 +716,17 @@ class _MessageWidgetState extends State final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && (showThreadReplyIndicator || showInChannel); - return Flex( - direction: Axis.horizontal, - clipBehavior: Clip.none, + return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (showThreadTail) Container( margin: EdgeInsets.only( - bottom: widget.messageTheme.replies.fontSize / 2, + bottom: context.textScaleFactor * + (widget.messageTheme.replies.fontSize / 2), ), child: CustomPaint( - size: const Size(16, 32), + size: Size(16, 32) * context.textScaleFactor, painter: _ThreadReplyPainter( context: context, color: widget.messageTheme.messageBorderColor, @@ -729,16 +734,20 @@ class _MessageWidgetState extends State ), ), ...children.map( - (child) => Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Container( - height: 16, - child: Center( + (child) { + Widget mappedChild = Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Container( + height: context.textScaleFactor * 14, child: child, ), - ), - ), + ); + if (child.key == usernameKey) { + mappedChild = Flexible(child: mappedChild); + } + return mappedChild; + }, ), ].insertBetween(const SizedBox(width: 8.0)), ); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 66b49d88..470591e7 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/stream_chat_flutter.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 /// @@ -121,4 +123,11 @@ class StreamChatState extends State { void initState() { super.initState(); } + + @override + void didChangeDependencies() { + final locale = ui.window.locale; + Jiffy.locale(locale.languageCode); + super.didChangeDependencies(); + } } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index eafa12c5..e54c0fbe 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -272,6 +272,7 @@ class StreamChatThemeData { sendAnimationDuration: Duration(milliseconds: 300), actionButtonColor: colorTheme.accentBlue, actionButtonIdleColor: colorTheme.grey, + expandButtonColor: colorTheme.accentBlue, sendButtonColor: colorTheme.accentBlue, sendButtonIdleColor: colorTheme.greyGainsboro, inputBackground: colorTheme.white, @@ -935,6 +936,9 @@ class MessageInputTheme { /// Background color of [MessageInput] action buttons final Color actionButtonIdleColor; + /// Background color of [MessageInput] expand button + final Color expandButtonColor; + /// Background color of [MessageInput] final Color inputBackground; @@ -966,6 +970,7 @@ class MessageInputTheme { this.activeBorderGradient, this.idleBorderGradient, this.borderRadius, + this.expandButtonColor, }); /// Returns a new [MessageInputTheme] replacing some of its properties @@ -976,6 +981,7 @@ class MessageInputTheme { Color sendButtonColor, Color actionButtonIdleColor, Color sendButtonIdleColor, + Color expandButtonColor, TextStyle inputTextStyle, InputDecoration inputDecoration, Gradient activeBorderGradient, @@ -990,6 +996,7 @@ class MessageInputTheme { sendButtonColor: sendButtonColor ?? this.sendButtonColor, actionButtonIdleColor: actionButtonIdleColor ?? this.actionButtonIdleColor, + expandButtonColor: expandButtonColor ?? this.expandButtonColor, inputTextStyle: inputTextStyle ?? this.inputTextStyle, sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, inputDecoration: inputDecoration ?? this.inputDecoration, @@ -1014,6 +1021,7 @@ class MessageInputTheme { activeBorderGradient: other.activeBorderGradient, idleBorderGradient: other.idleBorderGradient, borderRadius: other.borderRadius, + expandButtonColor: other.expandButtonColor, ); } } diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index 77537e59..d4bc2f72 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -193,9 +193,7 @@ class _UserListViewState extends State return child; } else { return RefreshIndicator( - onRefresh: () async { - _userListController.loadData(); - }, + onRefresh: () => _userListController.loadData(), child: child, ); } @@ -243,9 +241,7 @@ class _UserListViewState extends State child: Text(message), ), TextButton( - onPressed: () { - _userListController.loadData(); - }, + onPressed: () => _userListController.loadData(), child: Text('Retry'), ), ], @@ -300,9 +296,7 @@ class _UserListViewState extends State ); return LazyLoadScrollView( - onEndOfPage: () async { - return _userListController.paginateData(); - }, + onEndOfPage: () => _userListController.paginateData(), child: child, ); } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 74c0c33e..b8062209 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -39,4 +39,5 @@ export 'src/channel_media_display_screen.dart'; export 'src/info_tile.dart'; export 'src/stream_chat.dart'; export 'src/connection_status_builder.dart'; +export 'src/mention_tile.dart'; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index b17073d8..357bee09 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 1.5.1 +version: 1.5.2 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -11,7 +11,7 @@ environment: dependencies: flutter: sdk: flutter - stream_chat_flutter_core: ^1.5.0 + stream_chat_flutter_core: ^1.5.1 photo_view: ^0.11.0 rxdart: ^0.25.0 scrollable_positioned_list: ^0.1.8 @@ -30,16 +30,13 @@ dependencies: flutter_keyboard_visibility: ^4.0.2 video_compress: ^2.1.1 visibility_detector: ^0.1.5 - http_parser: ^3.1.4 meta: ^1.2.4 lottie: ^0.7.0+1 substring_highlight: ^0.1.2 flutter_slidable: ^0.5.7 - clipboard: ^0.1.2+8 image_gallery_saver: ^1.6.7 share_plus: ^1.2.0 photo_manager: ^1.0.0 - transparent_image: ^1.0.0 ezanimation: ^0.4.1 synchronized: ^2.1.0 characters: ^1.0.0 diff --git a/packages/stream_chat_flutter/test/src/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel_preview_test.dart index 34b9bf92..094c74a5 100644 --- a/packages/stream_chat_flutter/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -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('1'), findsOneWidget); expect(find.text('hello'), findsOneWidget); diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index ac1fc93c..0051b2c7 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.5.1 + +* Improved test coverage to > 90% +* Minor fixes and improvements + ## 1.5.0 * Minor fixes and improvements diff --git a/packages/stream_chat_flutter_core/analysis_options.yaml b/packages/stream_chat_flutter_core/analysis_options.yaml new file mode 100644 index 00000000..545d5492 --- /dev/null +++ b/packages/stream_chat_flutter_core/analysis_options.yaml @@ -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 \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart index 816bb052..3adea312 100644 --- a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/stream_chat_core.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. /// /// @@ -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 { /// Instantiate a new ChannelListView - ChannelListCore({ + const ChannelListCore({ Key key, @required this.errorBuilder, @required this.emptyBuilder, @@ -65,18 +66,31 @@ class ChannelListCore extends StatefulWidget { this.options, this.sort, this.pagination = const PaginationParams( - offset: 0, limit: 25, ), this.channelListController, - }) : assert(errorBuilder != null), - assert(emptyBuilder != null), - assert(loadingBuilder != null), - assert(listBuilder != null), + }) : assert( + errorBuilder != null, + 'Parameter errorBuilder should not be 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); /// 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; /// The builder that will be used in case of error @@ -103,9 +117,10 @@ class ChannelListCore extends StatefulWidget { final Map options; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. - /// Direction can be ascending or descending. + /// Sorting is based on field and direction, multiple sorting options can be + /// provided. + /// 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> sort; /// Pagination parameters @@ -115,10 +130,11 @@ class ChannelListCore extends StatefulWidget { final PaginationParams pagination; @override - _ChannelListCoreState createState() => _ChannelListCoreState(); + ChannelListCoreState createState() => ChannelListCoreState(); } -class _ChannelListCoreState extends State { +/// The current state of the [ChannelListCore]. +class ChannelListCoreState extends State { @override Widget build(BuildContext context) { final channelsBloc = ChannelsBloc.of(context); @@ -128,37 +144,25 @@ class _ChannelListCoreState extends State { StreamBuilder> _buildListView( ChannelsBlocState channelsBlocState, - ) { - return StreamBuilder>( - stream: channelsBlocState.channelsStream, - builder: (context, snapshot) { - if (snapshot.hasError) { - return _buildErrorWidget(snapshot, context, channelsBlocState); - } - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - final channels = snapshot.data; - if (channels.isEmpty) { - return widget.emptyBuilder(context); - } - return widget.listBuilder(context, channels); - }, - ); - } - - Widget _buildErrorWidget( - AsyncSnapshot> snapshot, - BuildContext context, - ChannelsBlocState channelsBlocState, - ) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } - - return widget.errorBuilder(context, snapshot.error); - } + ) => + StreamBuilder>( + stream: channelsBlocState.channelsStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder(context, snapshot.error); + } + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } + final channels = snapshot.data; + if (channels.isEmpty) { + return widget.emptyBuilder(context); + } + return widget.listBuilder(context, channels); + }, + ); + /// Fetches initial channels and updates the widget Future loadData() { final channelsBloc = ChannelsBloc.of(context); return channelsBloc.queryChannels( @@ -169,6 +173,7 @@ class _ChannelListCoreState extends State { ); } + /// Fetches more channels with updated pagination and updates the widget Future paginateData() { final channelsBloc = ChannelsBloc.of(context); return channelsBloc.queryChannels( @@ -209,9 +214,9 @@ class _ChannelListCoreState extends State { if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.options?.toString() != oldWidget.options?.toString() || widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString() || - widget.options?.toString() != oldWidget.options?.toString()) { + oldWidget.pagination?.toJson()?.toString()) { loadData(); } } @@ -223,14 +228,15 @@ class _ChannelListCoreState extends State { } } -/// Controller used for loading more data and controlling pagination in [ChannelListCore]. +/// Controller used for loading more data and controlling pagination in +/// [ChannelListCore]. class ChannelListController { - /// This function calls Stream's servers to load a list of channels. If there is existing data, - /// calling this function causes a reload. + /// This function calls Stream's servers to load a list of channels. + /// If there is existing data, calling this function causes a reload. AsyncCallback loadData; - /// This function is used to load another page of data. Note, [loadData] should be - /// used to populate the initial page of data. Calling [paginateData] performs a query - /// to load subsequent pages. + /// This function is used to load another page of data. Note, [loadData] + /// should be used to populate the initial page of data. Calling + /// [paginateData] performs a query to load subsequent pages. AsyncCallback paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 1ab84878..2b4968eb 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.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/ 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({ Key key, @required this.child, this.lockChannelsOrder = false, this.channelsComparator, this.shouldAddChannel, - }) : assert(child != null), + }) : assert(child != null, 'Parameter child should not be null.'), super(key: key); /// The 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; /// Comparator used to sort the channels when a message.new event is received final Comparator 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; @override @@ -70,10 +74,9 @@ class ChannelsBlocState extends State /// The current channel list as a stream Stream> get channelsStream => _channelsController.stream; - final BehaviorSubject _queryChannelsLoadingController = - BehaviorSubject.seeded(false); + final _queryChannelsLoadingController = BehaviorSubject.seeded(false); - final BehaviorSubject> _channelsController = BehaviorSubject(); + final _channelsController = BehaviorSubject>(); /// The stream notifying the state of queryChannel call Stream get queryChannelsLoading => @@ -81,45 +84,58 @@ class ChannelsBlocState extends State final List _hiddenChannels = []; + bool _paginationEnded = false; + /// Calls [client.queryChannels] updating [queryChannelsLoading] stream Future queryChannels({ Map filter, List> sortOptions, PaginationParams paginationParams, Map options, - bool onlyOffline = false, }) async { final client = StreamChatCore.of(context).client; - if (client.state?.user == null || - _queryChannelsLoadingController.value == true) { + if (_paginationEnded || _queryChannelsLoadingController.value == true) { return; } - _queryChannelsLoadingController.sink.add(true); + + if (_channelsController.hasValue) { + _queryChannelsLoadingController.add(true); + } try { final clear = paginationParams == null || paginationParams.offset == null || paginationParams.offset == 0; final oldChannels = List.from(channels ?? []); + var newChannels = []; await for (final channels in client.queryChannels( filter: filter, sort: sortOptions, options: options, paginationParams: paginationParams, )) { + newChannels = channels; if (clear) { _channelsController.add(channels); } else { - final l = oldChannels + channels; - _channelsController.add(l); + final temp = oldChannels + channels; + _channelsController.add(temp); + } + if (_channelsController.hasValue && + _queryChannelsLoadingController.value) { + _queryChannelsLoadingController.sink.add(false); } - _queryChannelsLoadingController.sink.add(false); } - } catch (err, stackTrace) { - print(err); - print(stackTrace); - _queryChannelsLoadingController.addError(err, stackTrace); + if (newChannels.isEmpty || newChannels.length < paginationParams.limit) { + _paginationEnded = true; + } + } catch (e, stk) { + if (_channelsController.hasValue) { + _queryChannelsLoadingController.addError(e, stk); + } else { + _channelsController.addError(e, stk); + } } } @@ -135,15 +151,14 @@ class ChannelsBlocState extends State _subscriptions.add(client.on(EventType.messageNew).listen((e) { final newChannels = List.from(channels ?? []); final index = newChannels.indexWhere((c) => c.cid == e.cid); - if (index > -1) { + if (index != -1) { if (index > 0) { final channel = newChannels.removeAt(index); newChannels.insert(0, channel); } - } else if (widget.shouldAddChannel != null && - widget.shouldAddChannel(e)) { + } else if (widget.shouldAddChannel?.call(e) == true) { final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid); - if (hiddenIndex > -1) { + if (hiddenIndex != -1) { newChannels.insert(0, _hiddenChannels[hiddenIndex]); _hiddenChannels.removeAt(hiddenIndex); } else { @@ -170,13 +185,14 @@ class ChannelsBlocState extends State _channelsController.add(newChannels); } })); - + // ignore: cascade_invocations _subscriptions.add(client .on( EventType.channelDeleted, EventType.notificationRemovedFromChannel, ) .listen((e) { + // ignore: cascade_invocations final channel = e.channel; _channelsController.add(List.from( (channels ?? [])..removeWhere((c) => c.cid == channel.cid))); diff --git a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart index 1820827f..808c1dae 100644 --- a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -enum _LoadingStatus { LOADING, STABLE } +enum _LoadingStatus { loading, stable } /// Wrapper around a [Scrollable] which triggers [onEndOfPage]/[onStartOfPage] the Scrollable /// reaches to the start or end of the view extent. class LazyLoadScrollView extends StatefulWidget { - /// Creates a new instance of [LazyLoadScrollView]. The parameter [child] must be - /// supplied and not null. + /// Creates a new instance of [LazyLoadScrollView]. The parameter [child] + /// must be supplied and not null. const LazyLoadScrollView({ Key key, @required this.child, @@ -17,7 +17,7 @@ class LazyLoadScrollView extends StatefulWidget { this.onPageScrollEnd, this.onInBetweenOfPage, this.scrollOffset = 100, - }) : assert(child != null), + }) : assert(child != null, 'Parameter child should not be null'), super(key: key); /// The [Widget] that this widget watches for changes on @@ -46,18 +46,17 @@ class LazyLoadScrollView extends StatefulWidget { } class _LazyLoadScrollViewState extends State { - _LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE; - double _scrollPosition = 0.0; + _LoadingStatus _loadMoreStatus = _LoadingStatus.stable; + double _scrollPosition = 0; @override - Widget build(BuildContext context) { - return NotificationListener( - child: widget.child, - onNotification: _onNotification, - ); - } + Widget build(BuildContext context) => + NotificationListener( + onNotification: _onNotification, + child: widget.child, + ); - bool _onNotification(Notification notification) { + bool _onNotification(ScrollNotification notification) { if (notification is ScrollStartNotification) { if (widget.onPageScrollStart != null) { widget.onPageScrollStart(); @@ -74,7 +73,7 @@ class _LazyLoadScrollViewState extends State { final pixels = notification.metrics.pixels; final maxScrollExtent = notification.metrics.maxScrollExtent; final minScrollExtent = notification.metrics.minScrollExtent; - final scrollOffset = widget.scrollOffset; + final scrollOffset = widget.scrollOffset ?? 0; if (pixels > (minScrollExtent + scrollOffset) && pixels < (maxScrollExtent - scrollOffset)) { @@ -88,26 +87,19 @@ class _LazyLoadScrollViewState extends State { final extentAfter = notification.metrics.extentAfter; final scrollingDown = _scrollPosition < pixels; - if (scrollOffset == null || scrollOffset == 0) { - if (extentAfter == 0) { + if (scrollingDown) { + if (extentAfter <= scrollOffset) { _onEndOfPage(); - } - if (extentBefore == 0) { - _onStartOfPage(); + return true; } } else { - if (scrollingDown) { - if (extentAfter <= scrollOffset) { - _onEndOfPage(); - } - } else { - if (extentBefore <= scrollOffset) { - _onStartOfPage(); - } + if (extentBefore <= scrollOffset) { + _onStartOfPage(); + return true; } } + _scrollPosition = pixels; - return true; } if (notification is OverscrollNotification) { if (notification.overscroll > 0) { @@ -122,22 +114,22 @@ class _LazyLoadScrollViewState extends State { } void _onEndOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { - _loadMoreStatus = _LoadingStatus.LOADING; + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { if (widget.onEndOfPage != null) { + _loadMoreStatus = _LoadingStatus.loading; widget.onEndOfPage().whenComplete(() { - _loadMoreStatus = _LoadingStatus.STABLE; + _loadMoreStatus = _LoadingStatus.stable; }); } } } void _onStartOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { - _loadMoreStatus = _LoadingStatus.LOADING; + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { if (widget.onStartOfPage != null) { + _loadMoreStatus = _LoadingStatus.loading; widget.onStartOfPage().whenComplete(() { - _loadMoreStatus = _LoadingStatus.STABLE; + _loadMoreStatus = _LoadingStatus.stable; }); } } diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 87f821c1..53844447 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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 '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. /// /// ```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. /// class MessageListCore extends StatefulWidget { /// Instantiate a new [MessageListView]. - MessageListCore({ + const MessageListCore({ Key key, @required this.loadingBuilder, @required this.emptyBuilder, @@ -65,10 +70,16 @@ class MessageListCore extends StatefulWidget { this.parentMessage, this.messageListController, this.messageFilter, - }) : assert(loadingBuilder != null), - assert(emptyBuilder != null), - assert(messageListBuilder != null), - assert(errorWidgetBuilder != null), + }) : assert(loadingBuilder != null, 'loadingBuilder should not be null'), + assert(emptyBuilder != null, 'emptyBuilder should not be null'), + assert( + messageListBuilder != null, + 'messageListBuilder should not be null', + ), + assert( + errorWidgetBuilder != null, + 'errorWidgetBuilder should not be null', + ), super(key: key); /// A [MessageListController] allows pagination. @@ -84,12 +95,15 @@ class MessageListCore extends StatefulWidget { /// Function used to build an empty widget final WidgetBuilder emptyBuilder; - /// Callback triggered when an error occurs while performing the given request. - /// This parameter can be used to display an error message to users in the event - /// of a connection failure. + /// Callback triggered when an error occurs while performing the given + /// request. + /// + /// This parameter can be used to display an error message to users in the + /// event of a connection failure. 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; /// 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; @override - _MessageListCoreState createState() => _MessageListCoreState(); + MessageListCoreState createState() => MessageListCoreState(); } -class _MessageListCoreState extends State { - StreamChannelState streamChannel; +/// The current state of the [MessageListCore]. +class MessageListCoreState extends State { + StreamChannelState _streamChannel; - bool get _upToDate => streamChannel.channel.state.isUpToDate; + bool get _upToDate => _streamChannel.channel.state.isUpToDate; bool get _isThreadConversation => widget.parentMessage != null; - OwnUser get _currentUser => streamChannel.channel.client.state.user; + OwnUser get _currentUser => _streamChannel.channel.client.state.user; - int initialIndex; - double initialAlignment; - - List messages = []; - - bool initialMessageHighlightComplete = false; + var _messages = []; @override Widget build(BuildContext context) { final messagesStream = _isThreadConversation - ? streamChannel.channel.state.threadsStream + ? _streamChannel.channel.state.threadsStream .where((threads) => threads.containsKey(widget.parentMessage.id)) .map((threads) => threads[widget.parentMessage.id]) - : streamChannel.channel.state?.messagesStream; + : _streamChannel.channel.state?.messagesStream; bool defaultFilter(Message m) { final isMyMessage = m.user.id == _currentUser.id; @@ -138,10 +148,10 @@ class _MessageListCoreState extends State { stream: messagesStream?.map((messages) => messages?.where(widget.messageFilter ?? defaultFilter)?.toList()), builder: (context, snapshot) { - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } else if (snapshot.hasError) { + if (snapshot.hasError) { return widget.errorWidgetBuilder(context, snapshot.error); + } else if (!snapshot.hasData) { + return widget.loadingBuilder(context); } else { final messageList = snapshot.data?.reversed?.toList() ?? []; if (messageList.isEmpty && !_isThreadConversation) { @@ -149,29 +159,32 @@ class _MessageListCoreState extends State { return widget.emptyBuilder(context); } } 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 paginateData( {QueryDirection direction = QueryDirection.bottom}) { if (!_isThreadConversation) { - return streamChannel.queryMessages(direction: direction); + return _streamChannel.queryMessages(direction: direction); } else { - return streamChannel.getReplies(widget.parentMessage.id); + return _streamChannel.getReplies(widget.parentMessage.id); } } @override void initState() { - streamChannel = StreamChannel.of(context); + _streamChannel = StreamChannel.of(context); if (_isThreadConversation) { - streamChannel.getReplies(widget.parentMessage.id); + _streamChannel.getReplies(widget.parentMessage.id); } if (widget.messageListController != null) { @@ -184,7 +197,7 @@ class _MessageListCoreState extends State { @override void dispose() { if (!_upToDate) { - streamChannel.reloadChannel(); + _streamChannel.reloadChannel(); } super.dispose(); } @@ -193,5 +206,5 @@ class _MessageListCoreState extends State { /// Controller used for paginating data in [ChannelListView] class MessageListController { /// Call this function to load further data - Function({QueryDirection direction}) paginateData; + Future Function({QueryDirection direction}) paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart index 71488cc8..1c5c78de 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; - -import 'stream_chat_core.dart'; +import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; /// [MessageSearchBloc] is used to manage a list of messages with pagination. /// This class can be used to load messages, perform queries, etc. @@ -16,7 +15,7 @@ class MessageSearchBloc extends StatefulWidget { const MessageSearchBloc({ Key key, @required this.child, - }) : assert(child != null), + }) : assert(child != null, 'Parameter child should not be null.'), super(key: key); /// The widget child @@ -49,17 +48,16 @@ class MessageSearchBlocState extends State Stream> get messagesStream => _messageResponses.stream; - final BehaviorSubject> _messageResponses = - BehaviorSubject(); + final _messageResponses = BehaviorSubject>(); - final BehaviorSubject _queryMessagesLoadingController = - BehaviorSubject.seeded(false); + final _queryMessagesLoadingController = BehaviorSubject.seeded(false); /// The stream notifying the state of queryUsers call Stream get queryMessagesLoading => _queryMessagesLoadingController.stream; - /// Calls [StreamChatClient.search] updating [messageResponses] stream + /// Calls [StreamChatClient.search] updating + /// [messagesStream] and [queryMessagesLoading] stream Future search({ Map filter, Map messageFilter, @@ -67,33 +65,13 @@ class MessageSearchBlocState extends State String query, PaginationParams pagination, }) async { - _messageResponses.add(null); - 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); - } - } + final client = StreamChatCore.of(context).client; - /// Calls [StreamChatClient.search] updating [queryMessagesLoading] stream - Future loadMore({ - Map filter, - Map messageFilter, - List sort, - String query, - PaginationParams pagination, - }) async { - if (_queryMessagesLoadingController.value == true) { - return; + if (_queryMessagesLoadingController.value == true) return; + + if (_messageResponses.hasValue) { + _queryMessagesLoadingController.add(true); } - _queryMessagesLoadingController.add(true); try { final clear = pagination == null || pagination.offset == null || @@ -101,12 +79,12 @@ class MessageSearchBlocState extends State final oldMessages = List.from(messageResponses ?? []); - final messages = await _search( - filter: filter, - messageFilter: messageFilter, + final messages = await client.search( + filter, sort: sort, query: query, - pagination: pagination, + paginationParams: pagination, + messageFilters: messageFilter, ); if (clear) { @@ -115,30 +93,18 @@ class MessageSearchBlocState extends State final temp = oldMessages + messages.results; _messageResponses.add(temp); } - - _queryMessagesLoadingController.add(false); - } catch (err, stackTrace) { - _queryMessagesLoadingController.addError(err, stackTrace); + if (_messageResponses.hasValue && _queryMessagesLoadingController.value) { + _queryMessagesLoadingController.add(false); + } + } catch (e, stk) { + if (_messageResponses.hasValue) { + _queryMessagesLoadingController.addError(e, stk); + } else { + _messageResponses.addError(e, stk); + } } } - Future _search({ - Map filter, - Map messageFilter, - List sort, - String query, - PaginationParams pagination, - }) { - final client = StreamChatCore.of(context).client; - return client.search( - filter, - sort: sort, - query: query, - paginationParams: pagination, - messageFilters: messageFilter, - ); - } - @override Widget build(BuildContext context) { super.build(context); diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart index e3d98426..72c5ea86 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -1,13 +1,15 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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 'message_search_bloc.dart'; /// -/// [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. +/// [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. /// /// ```dart /// class MessageSearchPage extends StatelessWidget { @@ -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. /// class MessageSearchListCore extends StatefulWidget { @@ -50,14 +53,16 @@ class MessageSearchListCore extends StatefulWidget { this.paginationParams, this.messageFilters, this.messageSearchListController, - }) : assert(emptyBuilder != null), - assert(errorBuilder != null), - assert(loadingBuilder != null), - assert(childBuilder != null), + }) : assert(emptyBuilder != null, 'emptyBuilder should not be null'), + assert(errorBuilder != null, 'errorBuilder should not be null'), + assert(loadingBuilder != null, 'loadingBuilder should not be null'), + assert(childBuilder != null, 'childBuilder should not be null'), super(key: key); /// 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; /// Message String to search on @@ -69,9 +74,10 @@ class MessageSearchListCore extends StatefulWidget { final Map filters; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. - /// Direction can be ascending or descending. + /// Sorting is based on field and direction, multiple sorting options can be + /// provided. + /// 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 sortOptions; /// Pagination parameters @@ -98,22 +104,15 @@ class MessageSearchListCore extends StatefulWidget { final WidgetBuilder loadingBuilder; @override - _MessageSearchListCoreState createState() => _MessageSearchListCoreState(); + MessageSearchListCoreState createState() => MessageSearchListCoreState(); } -class _MessageSearchListCoreState extends State { +/// The current state of the [MessageSearchListCore]. +class MessageSearchListCoreState extends State { @override - void initState() { - super.initState(); - final messageSearchBloc = MessageSearchBloc.of(context); - messageSearchBloc.search( - filter: widget.filters, - sort: widget.sortOptions, - query: widget.messageQuery, - pagination: widget.paginationParams, - messageFilter: widget.messageFilters, - ); - + void didChangeDependencies() { + super.didChangeDependencies(); + loadData(); if (widget.messageSearchListController != null) { widget.messageSearchListController.loadData = loadData; widget.messageSearchListController.paginateData = paginateData; @@ -126,37 +125,28 @@ class _MessageSearchListCoreState extends State { return _buildListView(messageSearchBloc); } - Widget _buildListView(MessageSearchBlocState messageSearchBloc) { - return StreamBuilder>( - stream: messageSearchBloc.messagesStream, - builder: (context, snapshot) { - if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); + Widget _buildListView(MessageSearchBlocState messageSearchBloc) => + StreamBuilder>( + stream: messageSearchBloc.messagesStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder(context, snapshot.error); } + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } + final items = snapshot.data; + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + return widget.childBuilder(snapshot.data); + }, + ); - return widget.errorBuilder(context, snapshot.error); - } - - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - - final items = snapshot.data; - - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - return widget.childBuilder(snapshot.data); - }, - ); - } - - void loadData() { + /// Fetches initial messages and updates the widget + Future loadData() { final messageSearchBloc = MessageSearchBloc.of(context); - - messageSearchBloc.search( + return messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, query: widget.messageQuery, @@ -165,10 +155,10 @@ class _MessageSearchListCoreState extends State { ); } - void paginateData() { + /// Fetches more messages with updated pagination and updates the widget + Future paginateData() { final messageSearchBloc = MessageSearchBloc.of(context); - - messageSearchBloc.loadMore( + return messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, pagination: widget.paginationParams.copyWith( @@ -184,19 +174,12 @@ class _MessageSearchListCoreState extends State { super.didUpdateWidget(oldWidget); if (widget.filters?.toString() != oldWidget.filters?.toString() || jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || - widget.paginationParams?.toJson()?.toString() != - oldWidget.paginationParams?.toJson()?.toString() || widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || widget.messageFilters?.toString() != - oldWidget.messageFilters?.toString()) { - final messageSearchBloc = MessageSearchBloc.of(context); - messageSearchBloc.search( - filter: widget.filters, - sort: widget.sortOptions, - query: widget.messageQuery, - pagination: widget.paginationParams, - messageFilter: widget.messageFilters, - ); + oldWidget.messageFilters?.toString() || + widget.paginationParams?.toJson()?.toString() != + oldWidget.paginationParams?.toJson()?.toString()) { + loadData(); } } } @@ -204,8 +187,8 @@ class _MessageSearchListCoreState extends State { /// Controller used for paginating data in [ChannelListView] class MessageSearchListController { /// Call this function to reload data - VoidCallback loadData; + AsyncCallback loadData; /// Call this function to load further data - VoidCallback paginateData; + AsyncCallback paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index c2333b05..8f86fca4 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -25,8 +26,8 @@ class StreamChannel extends StatefulWidget { @required this.channel, this.showLoading = true, this.initialMessageId, - }) : assert(child != null), - assert(channel != null), + }) : assert(child != null, 'Child should not be null'), + assert(channel != null, 'Channel should not be null'), super(key: key); // ignore: public_member_api_docs @@ -49,7 +50,8 @@ class StreamChannel extends StatefulWidget { if (streamChannelState == null) { 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; @@ -204,17 +206,15 @@ class StreamChannelState extends State { int before = 20, int after = 20, bool preferOffline = false, - }) { - return queryAtMessage( - messageId: messageId, - before: before, - after: after, - preferOffline: preferOffline, - ); - } + }) => + _queryAtMessage( + messageId: messageId, + before: before, + after: after, + preferOffline: preferOffline, + ); - /// - Future queryAtMessage({ + Future _queryAtMessage({ String messageId, int before = 20, int after = 20, @@ -254,15 +254,14 @@ class StreamChannelState extends State { String messageId, { int limit = 20, bool preferOffline = false, - }) { - return channel.query( - messagesPagination: PaginationParams( - lessThan: messageId, - limit: limit, - ), - preferOffline: preferOffline, - ); - } + }) => + channel.query( + messagesPagination: PaginationParams( + lessThan: messageId, + limit: limit, + ), + preferOffline: preferOffline, + ); /// Future queryAfterMessage( @@ -297,7 +296,7 @@ class StreamChannelState extends State { } /// Reloads the channel with latest message - Future reloadChannel() => queryAtMessage(before: 30); + Future reloadChannel() => _queryAtMessage(before: 30); List> _futures; @@ -305,8 +304,7 @@ class StreamChannelState extends State { try { await loadChannelAtMessage(initialMessageId); return true; - } catch (e, stk) { - print('Error: $e\nStack: $stk'); + } catch (_) { rethrow; } } @@ -349,9 +347,6 @@ class StreamChannelState extends State { ], builder: (context, snapshot) { if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } var message = snapshot.error.toString(); if (snapshot.error is DioError) { final dioError = snapshot.error as DioError; @@ -361,14 +356,13 @@ class StreamChannelState extends State { message = 'Check your connection and retry'; } } - return Center( - child: Text(message), - ); + return Center(child: Text(message)); } final initialized = snapshot.data[0]; + // ignore: avoid_bool_literals_in_conditional_expressions final dataLoaded = initialMessageId == null ? true : snapshot.data[1]; if (widget.showLoading && (!initialized || !dataLoaded)) { - return Center( + return const Center( child: CircularProgressIndicator(), ); } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 8a8d0185..15c2ddf3 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -3,13 +3,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; - -import 'typedef.dart'; +import 'package:stream_chat_flutter_core/src/typedef.dart'; /// 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. -/// When the app goes into the background, the websocket connection is kept alive -/// for two minutes before being terminated. +/// When the app goes into the background, the websocket connection is kept +/// alive for two minutes before being terminated. /// /// Conversely, when app is resumed or restarted, a new connection is initiated. /// @@ -36,16 +35,16 @@ import 'typedef.dart'; class StreamChatCore extends StatefulWidget { /// Constructor used for creating a new instance of [StreamChatCore]. /// - /// [StreamChatCore] is a stateful widget which reacts to system events and updates - /// Stream's connection status accordingly. - StreamChatCore({ + /// [StreamChatCore] is a stateful widget which reacts to system events and + /// updates Stream's connection status accordingly. + const StreamChatCore({ Key key, @required this.client, @required this.child, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), - }) : assert(client != null), - assert(child != null), + }) : assert(client != null, 'Stream Chat Client should not be null'), + assert(child != null, 'Child should not be null'), super(key: key); /// Instance of Stream Chat Client containing information about the current @@ -55,7 +54,8 @@ class StreamChatCore extends StatefulWidget { /// Widget descendant. 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; /// Handler called whenever the [client] receives a new [Event] while the app @@ -90,15 +90,13 @@ class StreamChatCoreState extends State Timer _disconnectTimer; @override - Widget build(BuildContext context) { - return widget.child; - } + Widget build(BuildContext context) => widget.child; /// The current user - User get user => widget.client.state.user; + User get user => client.state?.user; /// The current user as a stream - Stream get userStream => widget.client.state.userStream; + Stream get userStream => client.state?.userStream; @override void initState() { @@ -110,21 +108,25 @@ class StreamChatCoreState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (client.state?.user != null) { + if (user != null) { if (state == AppLifecycleState.paused) { - if (widget.onBackgroundEventReceived != null) { - _eventSubscription = - client.on().listen(widget.onBackgroundEventReceived); - _disconnectTimer = Timer( - widget.backgroundKeepAlive, - client.disconnect, - ); - } else { + if (widget.onBackgroundEventReceived == null) { + client.disconnect(); + return; + } + _eventSubscription = client.on().listen( + widget.onBackgroundEventReceived, + ); + + void onTimerComplete() { + _eventSubscription.cancel(); client.disconnect(); } + + _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); } else if (state == AppLifecycleState.resumed) { - _eventSubscription?.cancel(); if (_disconnectTimer?.isActive == true) { + _eventSubscription.cancel(); _disconnectTimer.cancel(); } else { if (client.wsConnectionStatus == ConnectionStatus.disconnected) { diff --git a/packages/stream_chat_flutter_core/lib/src/typedef.dart b/packages/stream_chat_flutter_core/lib/src/typedef.dart index 29fb2d31..ace8d7e6 100644 --- a/packages/stream_chat_flutter_core/lib/src/typedef.dart +++ b/packages/stream_chat_flutter_core/lib/src/typedef.dart @@ -2,8 +2,8 @@ import 'package:flutter/widgets.dart'; import 'package:stream_chat/stream_chat.dart'; /// 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 -/// is unable to render data. +/// This Callback can be used in cases where an API failure occurs and the +/// widget is unable to render data. typedef ErrorBuilder = Widget Function(BuildContext context, Object error); /// A Signature for a handler function which will expose a [event]. diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart index 23140258..f1bd68ee 100644 --- a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -1,11 +1,13 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.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. /// /// ```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 /// [UserListCore] depends on functionality contained within [UsersBloc]. /// -/// The parameters [listBuilder], [loadingBuilder], [emptyBuilder] and [errorBuilder] must all be supplied -/// and not null. +/// The parameters [listBuilder], [loadingBuilder], [emptyBuilder] and +/// [errorBuilder] must all be supplied and not null. class UserListCore extends StatefulWidget { /// Instantiate a new [UserListCore] const UserListCore({ - Key key, @required this.errorBuilder, @required this.emptyBuilder, @required this.loadingBuilder, @required this.listBuilder, + Key key, this.filter, this.options, this.sort, this.pagination, this.groupAlphabetically = false, this.userListController, - }) : assert(errorBuilder != null), - assert(emptyBuilder != null), - assert(loadingBuilder != null), - assert(listBuilder != null), + }) : assert(errorBuilder != null, ''), + assert(emptyBuilder != null, ''), + assert(loadingBuilder != null, ''), + assert(listBuilder != null, ''), super(key: key); /// 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; /// 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 final Widget Function(BuildContext context, List users) listBuilder; @@ -100,9 +103,9 @@ class UserListCore extends StatefulWidget { final Map options; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. - /// Direction can be ascending or descending. + /// Sorting is based on field and direction, multiple sorting options can be + /// provided. 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 sort; /// Pagination parameters @@ -117,22 +120,16 @@ class UserListCore extends StatefulWidget { final bool groupAlphabetically; @override - _UserListCoreState createState() => _UserListCoreState(); + UserListCoreState createState() => UserListCoreState(); } -class _UserListCoreState extends State +/// The current state of the [UserListCore]. +class UserListCoreState extends State with WidgetsBindingObserver { @override - void initState() { - super.initState(); - final usersBloc = UsersBloc.of(context); - usersBloc.queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination, - options: widget.options, - ); - + void didChangeDependencies() { + super.didChangeDependencies(); + loadData(); if (widget.userListController != null) { widget.userListController.loadData = loadData; widget.userListController.paginateData = paginateData; @@ -142,77 +139,64 @@ class _UserListCoreState extends State @override Widget build(BuildContext context) { final _usersBloc = UsersBloc.of(context); - return _buildListView(_usersBloc); } - bool get isListAlreadySorted => + bool get _isListAlreadySorted => widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; Stream> _buildUserStream( UsersBlocState usersBlocState, - ) { - return usersBlocState.usersStream.map( - (users) { - if (widget.groupAlphabetically) { - var temp = users; - if (!isListAlreadySorted) { - temp = users..sort((curr, next) => curr.name.compareTo(next.name)); + ) => + usersBlocState.usersStream.map( + (users) { + if (widget.groupAlphabetically) { + var temp = users; + if (!_isListAlreadySorted) { + temp = users + ..sort((curr, next) => curr.name.compareTo(next.name)); + } + final groupedUsers = >{}; + for (final e in temp) { + final alphabet = e.name[0]?.toUpperCase(); + groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; + } + final items = []; + for (final key in groupedUsers.keys) { + items + ..add(ListHeaderItem(key)) + ..addAll(groupedUsers[key].map((e) => ListUserItem(e))); + } + return items; } - final groupedUsers = >{}; - for (final e in temp) { - final alphabet = e.name[0]?.toUpperCase(); - groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; - } - final items = []; - for (final key in groupedUsers.keys) { - items.add(ListHeaderItem(key)); - items.addAll(groupedUsers[key].map((e) => ListUserItem(e))); - } - return items; - } - return users.map((e) => ListUserItem(e)).toList(); - }, - ); - } + return users.map((e) => ListUserItem(e)).toList(); + }, + ); StreamBuilder> _buildListView( UsersBlocState usersBlocState, - ) { - return StreamBuilder( - stream: _buildUserStream(usersBlocState), - builder: (context, snapshot) { - if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); + ) => + StreamBuilder( + stream: _buildUserStream(usersBlocState), + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder(snapshot.error); } + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } + final items = snapshot.data; + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + return widget.listBuilder(context, items); + }, + ); - return widget.errorBuilder(snapshot.error); - } - - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - - final items = snapshot.data; - - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - return widget.listBuilder(context, items); - }, - ); - } - - void loadData() { + // ignore: public_member_api_docs + Future loadData() { final _usersBloc = UsersBloc.of(context); - - _usersBloc.queryUsers( + return _usersBloc.queryUsers( filter: widget.filter, sort: widget.sort, pagination: widget.pagination, @@ -220,10 +204,10 @@ class _UserListCoreState extends State ); } - void paginateData() { + // ignore: public_member_api_docs + Future paginateData() { final _usersBloc = UsersBloc.of(context); - - _usersBloc.queryUsers( + return _usersBloc.queryUsers( filter: widget.filter, sort: widget.sort, pagination: widget.pagination.copyWith( @@ -238,29 +222,23 @@ class _UserListCoreState extends State super.didUpdateWidget(oldWidget); if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.options?.toString() != oldWidget.options?.toString() || widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString() || - widget.options?.toString() != oldWidget.options?.toString()) { - final usersBloc = UsersBloc.of(context); - usersBloc.queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination, - options: widget.options, - ); + oldWidget.pagination?.toJson()?.toString()) { + loadData(); } } } /// Represents an item in a the user stream list. -/// Header items are prefixed with the key `HEADER` While users are prefixed with -/// `USER`. +/// Header items are prefixed with the key `HEADER` While users are prefixed +/// with `USER`. abstract class ListItem { - // ignore: public_member_api_docs + /// Unique key per list item String get key { if (this is ListHeaderItem) { final header = (this as ListHeaderItem).heading; - return 'HEADER-$header'; + return 'HEADER-${header.toLowerCase()}'; } if (this is ListUserItem) { final user = (this as ListUserItem).user; @@ -269,7 +247,8 @@ abstract class ListItem { return null; } - // ignore: public_member_api_docs + /// Helper function to build widget based on ListItem type + // ignore: missing_return Widget when({ @required Widget Function(String heading) headerItem, @required Widget Function(User user) userItem, @@ -280,33 +259,32 @@ abstract class ListItem { if (this is ListUserItem) { return userItem((this as ListUserItem).user); } - return SizedBox(); } } -// ignore: public_member_api_docs +/// Header Item class ListHeaderItem extends ListItem { - // ignore: public_member_api_docs - final String heading; - - // ignore: public_member_api_docs + /// Constructs a new [ListHeaderItem] ListHeaderItem(this.heading); + + /// Heading used to build the item. + final String heading; } -// ignore: public_member_api_docs +/// User Item class ListUserItem extends ListItem { - // ignore: public_member_api_docs - final User user; - - // ignore: public_member_api_docs + /// Constructs a new [ListUserItem] ListUserItem(this.user); + + /// [User] used to build the item. + final User user; } /// Controller used for paginating data in [ChannelListView] class UserListController { /// Call this function to reload data - VoidCallback loadData; + AsyncCallback loadData; /// Call this function to load further data - VoidCallback paginateData; + AsyncCallback paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart index 19c4a026..92881619 100644 --- a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -1,8 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; - -import 'stream_chat_core.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// 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 /// not null. const UsersBloc({ - Key key, @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); /// The widget child @@ -48,10 +51,9 @@ class UsersBlocState extends State /// The current users list as a stream Stream> get usersStream => _usersController.stream; - final BehaviorSubject> _usersController = BehaviorSubject(); + final _usersController = BehaviorSubject>(); - final BehaviorSubject _queryUsersLoadingController = - BehaviorSubject.seeded(false); + final _queryUsersLoadingController = BehaviorSubject.seeded(false); /// The stream notifying the state of queryUsers call Stream get queryUsersLoading => _queryUsersLoadingController.stream; @@ -67,11 +69,12 @@ class UsersBlocState extends State }) async { final client = StreamChatCore.of(context).client; - if (client.state?.user == null || - _queryUsersLoadingController.value == true) { - return; + if (_queryUsersLoadingController.value == true) return; + + if (_usersController.hasValue) { + _queryUsersLoadingController.add(true); } - _queryUsersLoadingController.add(true); + try { final clear = pagination == null || pagination.offset == null || @@ -92,10 +95,15 @@ class UsersBlocState extends State final temp = oldUsers + usersResponse.users; _usersController.add(temp); } - - _queryUsersLoadingController.add(false); - } catch (err, stackTrace) { - _queryUsersLoadingController.addError(err, stackTrace); + if (_usersController.hasValue && _queryUsersLoadingController.value) { + _queryUsersLoadingController.add(false); + } + } catch (e, stk) { + if (_usersController.hasValue) { + _queryUsersLoadingController.addError(e, stk); + } else { + _usersController.addError(e, stk); + } } } diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 48d31b45..01463de0 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -1,14 +1,15 @@ 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/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_list_core.dart'; +export 'src/message_search_list_core.dart' hide MessageSearchListCoreState; export 'src/stream_channel.dart'; export 'src/stream_chat_core.dart'; -export 'src/user_list_core.dart'; -export 'src/users_bloc.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'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 5a5fc7da..0a5844f7 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 1.5.0 +version: 1.5.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -10,15 +10,13 @@ environment: flutter: ">=1.17.0" dependencies: - stream_chat: ^1.5.0 flutter: sdk: flutter + meta: ^1.2.4 rxdart: ^0.25.0 + stream_chat: ^1.5.1 dev_dependencies: - mockito: ^4.1.4 flutter_test: sdk: flutter - fake_async: ^1.1.0 - pedantic: ^1.9.2 - \ No newline at end of file + mockito: ^4.1.3 \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart new file mode 100644 index 00000000..b0fb9ded --- /dev/null +++ b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart @@ -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 _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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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()); + }, + ); + + 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 = []; + 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( + 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart new file mode 100644 index 00000000..e021e9b7 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart @@ -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 _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())); + }, + ); + + 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()); + }, + ); + + 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( + 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( + 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( + 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( + 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 eventController; + setUp(() { + eventController = StreamController.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( + 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( + 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( + 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(); + + 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( + 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( + 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( + 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(); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart new file mode 100644 index 00000000..19c1f2a2 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart @@ -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())); + }, + ); + + 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart new file mode 100644 index 00000000..f371acff --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart @@ -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 targetChannelList) => + _IsSameChannelListAs(targetChannelList: targetChannelList); + +class _IsSameChannelListAs extends Matcher { + const _IsSameChannelListAs({ + @required this.targetChannelList, + }) : assert(targetChannelList != null, ''); + + final List targetChannelList; + + @override + bool matches(covariant List 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'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart new file mode 100644 index 00000000..bd89420b --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart @@ -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 targetResponseList) => + _IsSameMessageResponseListAs(targetResponseList: targetResponseList); + +class _IsSameMessageResponseListAs extends Matcher { + const _IsSameMessageResponseListAs({ + @required this.targetResponseList, + }) : assert(targetResponseList != null, ''); + + final List targetResponseList; + + @override + bool matches( + covariant List 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'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart new file mode 100644 index 00000000..12a3e19d --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart @@ -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 targetMessageList) => + _IsSameMessageListAs(targetMessageList: targetMessageList); + +class _IsSameMessageListAs extends Matcher { + const _IsSameMessageListAs({ + @required this.targetMessageList, + }) : assert(targetMessageList != null, ''); + + final List targetMessageList; + + @override + bool matches(covariant List 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'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart new file mode 100644 index 00000000..3816fa24 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart @@ -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 targetUserList) => + _IsSameUserListAs(targetUserList: targetUserList); + +class _IsSameUserListAs extends Matcher { + const _IsSameUserListAs({ + @required this.targetUserList, + }) : assert(targetUserList != null, ''); + + final List targetUserList; + + @override + bool matches(covariant List 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'); +} diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart new file mode 100644 index 00000000..95446f95 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -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 _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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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()); + }, + ); + + 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 = []; + 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 = []; + 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, + ); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart new file mode 100644 index 00000000..e639c03b --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart @@ -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 _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())); + }, + ); + + 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( + find.byKey(messageSearchBlocKey), + ); + + try { + await usersBlocState.search(); + } catch (e) { + expect(e, isInstanceOf()); + } + }, + ); + + 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( + 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( + 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( + 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( + 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart new file mode 100644 index 00000000..aa4a47f9 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart @@ -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 _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())); + }, + ); + + test( + 'should throw assertion error in case loadingBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: null, + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case emptyBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: null, + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case errorBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: null, + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + 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 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()); + }, + ); + + testWidgets( + 'should render MessageSearchListCore if used with ' + 'MessageSearchListCore as an ancestor', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List 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 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 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 messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + final messageResponseList = []; + 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 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 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( + 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 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index c23a4be4..a46b7b5c 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -1,10 +1,39 @@ import 'package:mockito/mockito.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 {} diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart new file mode 100644 index 00000000..4387a918 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -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 _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())); + }, + ); + + 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())); + }, + ); + + 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 40880dfc..4adc0062 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -1,181 +1,289 @@ import 'dart:async'; -import 'package:fake_async/fake_async.dart'; import 'package:flutter/material.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:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; -class MockShowLocalNotifications extends Mock { +class MockOnBackgroundEventReceived extends Mock { void call(Event event); } void main() { - testWidgets( - 'StreamChatCore.of(context) should throw an exception', - (WidgetTester tester) async { - await tester.pumpWidget( - Builder( - builder: (context) { - expect(() => StreamChatCore.of(context), throwsException); - return Container(); - }, - ), - ); + test( + 'should throw assertion error in case client is null', + () { + final streamChatCore = () => StreamChatCore( + client: null, + child: Offstage(), + ); + expect(streamChatCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case child is null', + () { + final mockClient = MockClient(); + final streamChatCore = () => StreamChatCore( + client: mockClient, + child: null, + ); + expect(streamChatCore, throwsA(isA())); }, ); testWidgets( - 'StreamChatCore.of(context) should return the StreamChatCore ancestor', - (WidgetTester tester) async { - final client = MockClient(); - await tester.pumpWidget( - StreamChatCore( - client: client, - child: Builder( - builder: (context) { - final sc = StreamChatCore.of(context); - expect(sc, isNotNull); - return Container(); - }, - ), - ), + 'should render StreamChatCore if both client and child is provided', + (tester) async { + 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); }, ); testWidgets( - 'StreamChatCore.of(context).client should return the client', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final userStream = Stream.value( - OwnUser( - id: 'test', - ), - ); - when(clientState.userStream).thenAnswer( - (_) => userStream, + 'should render StreamChatCore if both client and child is provided', + (tester) async { + 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( - client: client, - child: Builder( - builder: (context) { - final sc = StreamChatCore.of(context); - expect(sc.client, client); - expect(sc.user, client.state.user); - expect(sc.userStream, userStream); - return Container(); - }, - ), - ), - ); + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); }, ); testWidgets( - 'StreamChatCore should disconnect on background', - (WidgetTester tester) async { - fakeAsync((_async) { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final showLocalNotificationMock = MockShowLocalNotifications().call; - final eventStreamController = StreamController(); - when(client.on()).thenAnswer((_) => eventStreamController.stream); + 'didChangeAppLifecycleState should call client.disconnect() and return ' + 'if onBackgroundEventReceived is null and the widget lifestyle changes to ' + 'AppLifecycleState.paused', + (tester) async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + ); - when(client.channel('test', id: 'testid')).thenReturn(channel); + await tester.pumpWidget(streamChatCore); - final scKey = GlobalKey(); - tester.pumpWidget( - StreamChatCore( - key: scKey, - client: client, - onBackgroundEventReceived: showLocalNotificationMock, - backgroundKeepAlive: const Duration(seconds: 4), - child: Builder( - builder: (context) { - return Container(); - }, - ), - ), + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + when(mockClient.disconnect()).thenAnswer((_) async { + return; + }); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + streamChatCoreState.didChangeAppLifecycleState(AppLifecycleState.paused); + + verify(mockClient.disconnect()).called(1); + }, + ); + + testWidgets( + '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, ); - final sc = scKey.currentState; - sc.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pumpWidget(streamChatCore); - _async.elapse(Duration(seconds: 5)); + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); - verify(client.disconnect()).called(1); - eventStreamController.close(); + final event = Event(); + when(mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(mockClient.disconnect()).thenAnswer((_) async { + return; + }); + + final streamChatCoreState = tester.state( + 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( - 'StreamChatCore should handle notifications when on background and connected', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final showLocalNotificationMock = MockShowLocalNotifications().call; - final eventStreamController = StreamController(); - when(client.on()).thenAnswer((_) => eventStreamController.stream); + 'didChangeAppLifecycleState should cancel the backgroundKeepAlive timer ' + 'if it is currently running in case the widget lifestyle changes to ' + 'AppLifecycleState.resume', + (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, + ); - when(client.channel('test', id: 'testid')).thenReturn(channel); + await tester.pumpWidget(streamChatCore); - final scKey = GlobalKey(); - await tester.pumpWidget( - StreamChatCore( - key: scKey, - client: client, - onBackgroundEventReceived: showLocalNotificationMock, - backgroundKeepAlive: const Duration(seconds: 4), - child: Builder( - builder: (context) { - return Container(); - }, - ), - ), - ); + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); - final sc = scKey.currentState; - sc.didChangeAppLifecycleState(AppLifecycleState.paused); - final event = Event( - type: EventType.messageNew, - message: Message(text: 'hey'), - channelType: 'test', - channelId: 'testid', - user: User(id: 'other user'), - ); - eventStreamController.add(event); + final event = Event(); + when(mockClient.on()).thenAnswer((_) => Stream.value(event)); - await untilCalled(showLocalNotificationMock(event)); + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); - verify(showLocalNotificationMock(event)).called(1); - eventStreamController.close(); + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await untilCalled(mockOnBackgroundEventReceived.call(event)); + + verify(mockOnBackgroundEventReceived.call(event)).called(1); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verifyNever(mockOnBackgroundEventReceived.call(event)); + }); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should call client.connect() ' + 'if the connectionStatus is ConnectionStatus.disconnected in case the ' + 'widget lifestyle changes to AppLifecycleState.resume', + (tester) async { + await tester.runAsync(() async { + 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); + + 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( + 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(); + + 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( + find.byKey(streamChatCoreKey), + ); + + final ownUser = OwnUser(id: 'testUserId'); + userController.add(ownUser); + + await expectLater( + streamChatCoreState.userStream, + emits(ownUser), + ); + + addTearDown(() { + userController.close(); + }); + }); }, ); } diff --git a/packages/stream_chat_flutter_core/test/user_list_core_test.dart b/packages/stream_chat_flutter_core/test/user_list_core_test.dart new file mode 100644 index 00000000..d1c7e83a --- /dev/null +++ b/packages/stream_chat_flutter_core/test/user_list_core_test.dart @@ -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 _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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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())); + }, + ); + + 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()); + }, + ); + + 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 = []; + 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( + 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); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/users_bloc_test.dart b/packages/stream_chat_flutter_core/test/users_bloc_test.dart new file mode 100644 index 00000000..e9ccd2e7 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/users_bloc_test.dart @@ -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 _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())); + }, + ); + + 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( + find.byKey(usersBlocKey), + ); + + try { + await usersBlocState.queryUsers(); + } catch (e) { + expect(e, isInstanceOf()); + } + }, + ); + + 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( + 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( + 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( + 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( + 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); + }, + ); +} diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 9c844ee3..cf2fc54b 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.5.1 + +* Improved test coverage to > 95% +* Minor fixes and improvements + ## 1.5.0 * Update llc dependency diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index 785b5946..233fd4ef 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -32,7 +32,7 @@ class ChannelQueryDao extends DatabaseAccessor Future updateChannelQueries( Map filter, List cids, { - bool clearQueryCache, + bool clearQueryCache = false, }) async => transaction(() async { final hash = _computeHash(filter); @@ -57,6 +57,14 @@ class ChannelQueryDao extends DatabaseAccessor }); }); + /// + Future> getCachedChannelCids(Map 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 Future> getChannels({ Map filter, @@ -72,12 +80,7 @@ class ChannelQueryDao extends DatabaseAccessor return true; }(), ''); - final hash = _computeHash(filter); - final cachedChannelCids = await (select(channelQueries) - ..where((c) => c.queryHash.equals(hash))) - .map((c) => c.channelCid) - .get(); - + final cachedChannelCids = await getCachedChannelCids(filter); final query = select(channels)..where((c) => c.cid.isIn(cachedChannelCids)); final cachedChannels = await (query.join([ diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart index 94b0d333..50528bd2 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart @@ -2,14 +2,13 @@ import 'package:moor/moor.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/entity/connection_events.dart'; -import 'package:stream_chat_persistence/src/entity/users.dart'; import 'package:stream_chat_persistence/src/mapper/mapper.dart'; part 'connection_event_dao.g.dart'; /// The Data Access Object for operations in [ConnectionEvents] table. -@UseDao(tables: [ConnectionEvents, Users]) +@UseDao(tables: [ConnectionEvents]) class ConnectionEventDao extends DatabaseAccessor with _$ConnectionEventDaoMixin { /// Creates a new connection event dao instance diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart index a8245982..28eb0913 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart @@ -9,5 +9,4 @@ part of 'connection_event_dao.dart'; mixin _$ConnectionEventDaoMixin on DatabaseAccessor { $ConnectionEventsTable get connectionEvents => attachedDatabase.connectionEvents; - $UsersTable get users => attachedDatabase.users; } diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart index 0be4446b..8d36b758 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -72,7 +72,7 @@ class MessageDao extends DatabaseAccessor /// [Messages.channelCid] with [cid] Future> getThreadMessages(String cid) async => Future.wait(await (select(messages).join([ - leftOuterJoin(users, messages.userId.equalsExp(_users.id)), + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), leftOuterJoin(_pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) @@ -89,18 +89,36 @@ class MessageDao extends DatabaseAccessor PaginationParams options, }) async { final msgList = await Future.wait(await (select(messages).join([ - innerJoin(_users, messages.userId.equalsExp(_users.id)), - innerJoin( + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), + leftOuterJoin( _pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) + ..where(isNotNull(messages.parentId)) ..where(messages.parentId.equals(parentId)) ..orderBy([OrderingTerm.asc(messages.createdAt)])) .map(_messageFromJoinRow) .get()); - if (options?.lessThan != null && msgList.isNotEmpty) { - final lessThanIndex = msgList.indexWhere((m) => m.id == options.lessThan); - msgList.removeRange(lessThanIndex, msgList.length); + if (msgList.isNotEmpty) { + if (options?.lessThan != null) { + final lessThanIndex = msgList.indexWhere( + (m) => m.id == options.lessThan, + ); + if (lessThanIndex != -1) { + 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; } @@ -123,24 +141,26 @@ class MessageDao extends DatabaseAccessor .map(_messageFromJoinRow) .get()); - if (messagePagination?.lessThan != null) { - final lessThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.lessThan, - ); - if (lessThanIndex != -1) { - msgList.removeRange(lessThanIndex, msgList.length); + if (msgList.isNotEmpty) { + if (messagePagination?.lessThan != null) { + final lessThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.lessThan, + ); + if (lessThanIndex != -1) { + msgList.removeRange(lessThanIndex, msgList.length); + } } - } - if (messagePagination?.greaterThanOrEqual != null) { - final greaterThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.greaterThanOrEqual, - ); - if (greaterThanIndex != -1) { - msgList.removeRange(0, greaterThanIndex); + if (messagePagination?.greaterThanOrEqual != null) { + final greaterThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.greaterThanOrEqual, + ); + if (greaterThanIndex != -1) { + msgList.removeRange(0, greaterThanIndex); + } + } + if (messagePagination?.limit != null) { + return msgList.take(messagePagination.limit).toList(); } - } - if (messagePagination?.limit != null) { - return msgList.take(messagePagination.limit).toList(); } return msgList; } diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart index 5e0ae721..34ee4c28 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart @@ -89,18 +89,36 @@ class PinnedMessageDao extends DatabaseAccessor PaginationParams options, }) async { final msgList = await Future.wait(await (select(pinnedMessages).join([ - innerJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), - innerJoin(_pinnedByUsers, + leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), + leftOuterJoin(_pinnedByUsers, pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) + ..where(isNotNull(pinnedMessages.parentId)) ..where(pinnedMessages.parentId.equals(parentId)) ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) .map(_messageFromJoinRow) .get()); - if (options?.lessThan != null) { - final lessThanIndex = msgList.indexWhere((m) => m.id == options.lessThan); - msgList.removeRange(lessThanIndex, msgList.length); + if (msgList.isNotEmpty) { + if (options?.lessThan != null) { + final lessThanIndex = msgList.indexWhere( + (m) => m.id == options.lessThan, + ); + if (lessThanIndex != -1) { + 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; } @@ -123,24 +141,26 @@ class PinnedMessageDao extends DatabaseAccessor .map(_messageFromJoinRow) .get()); - if (messagePagination?.lessThan != null) { - final lessThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.lessThan, - ); - if (lessThanIndex != -1) { - msgList.removeRange(lessThanIndex, msgList.length); + if (msgList.isNotEmpty) { + if (messagePagination?.lessThan != null) { + final lessThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.lessThan, + ); + if (lessThanIndex != -1) { + msgList.removeRange(lessThanIndex, msgList.length); + } } - } - if (messagePagination?.greaterThanOrEqual != null) { - final greaterThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.greaterThanOrEqual, - ); - if (greaterThanIndex != -1) { - msgList.removeRange(0, greaterThanIndex); + if (messagePagination?.greaterThanOrEqual != null) { + final greaterThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.greaterThanOrEqual, + ); + if (greaterThanIndex != -1) { + msgList.removeRange(0, greaterThanIndex); + } + } + if (messagePagination?.limit != null) { + return msgList.take(messagePagination.limit).toList(); } - } - if (messagePagination?.limit != null) { - return msgList.take(messagePagination.limit).toList(); } return msgList; } diff --git a/packages/stream_chat_persistence/lib/src/dao/user_dao.dart b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart index 65ce1521..9cdff8e7 100644 --- a/packages/stream_chat_persistence/lib/src/dao/user_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart @@ -20,4 +20,10 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { mode: InsertMode.insertOrReplace, ), ); + + /// Returns the list of all the users stored in db + Future> getUsers() => + (select(users)..orderBy([(u) => OrderingTerm.desc(u.createdAt)])) + .map((it) => it.toUser()) + .get(); } diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart index 65686cb1..73c7b0fa 100644 --- a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart @@ -1,22 +1,15 @@ +import 'package:meta/meta.dart'; +import 'package:moor/ffi.dart'; import 'package:moor/moor.dart'; import 'package:stream_chat/stream_chat.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/db/shared/shared_db.dart'; import 'package:stream_chat_persistence/src/entity/entity.dart'; -part 'moor_chat_database.g.dart'; +export 'shared/shared_db.dart'; -LazyDatabase _openConnection( - String userId, { - bool logStatements = false, - bool persistOnDisk = true, -}) => - LazyDatabase(() async => SharedDB.constructDatabase( - userId, - logStatements: logStatements, - persistOnDisk: persistOnDisk, - )); +part 'moor_chat_database.g.dart'; /// A chat database implemented using moor @UseMoor(tables: [ @@ -43,14 +36,9 @@ LazyDatabase _openConnection( class MoorChatDatabase extends _$MoorChatDatabase { /// Creates a new moor chat database instance MoorChatDatabase( - this._userId, { - logStatements = false, - bool persistOnDisk = true, - }) : super(_openConnection( - _userId, - logStatements: logStatements, - persistOnDisk: persistOnDisk, - )); + this._userId, + QueryExecutor executor, + ) : super(executor); /// Instantiate a new database instance MoorChatDatabase.connect( @@ -58,6 +46,10 @@ class MoorChatDatabase extends _$MoorChatDatabase { DatabaseConnection connection, ) : super.connect(connection); + /// Custom constructor used only for testing + @visibleForTesting + MoorChatDatabase.testable(this._userId) : super(VmDatabase.memory()); + final String _userId; /// User id to which the database is connected diff --git a/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart index 7592247e..27aa23b5 100644 --- a/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart +++ b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'dart:io'; import 'dart:isolate'; @@ -14,27 +15,49 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; /// A Helper class to construct new instances of [MoorChatDatabase] specifically /// for native platform applications class SharedDB { - /// Returns a new instance of [VmDatabase] created using [userId] - /// on a regular isolate. - /// - /// Generally used with [ConnectionMode.regular]. - static Future constructDatabase( + /// Returns a new instance of [MoorChatDatabase]. + static MoorChatDatabase constructDatabase( String userId, { bool logStatements = false, - bool persistOnDisk = true, - }) async { + ConnectionMode connectionMode = ConnectionMode.regular, + }) { final dbName = 'db_$userId'; - if (persistOnDisk) { - if (Platform.isIOS || Platform.isAndroid) { - final dir = await getApplicationDocumentsDirectory(); - final path = join(dir.path, '$dbName.sqlite'); - final file = File(path); - return VmDatabase(file, logStatements: logStatements); - } - if (Platform.isMacOS || Platform.isLinux) { - final file = File('$dbName.sqlite'); - return VmDatabase(file, logStatements: logStatements); - } + 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 _constructDatabase( + String dbName, { + bool logStatements = false, + }) async { + if (Platform.isIOS || Platform.isAndroid) { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, '$dbName.sqlite'); + final file = File(path); + return VmDatabase(file, logStatements: logStatements); + } + if (Platform.isMacOS || Platform.isLinux) { + final file = File('$dbName.sqlite'); + return VmDatabase(file, logStatements: logStatements); } return VmDatabase.memory(logStatements: logStatements); } @@ -69,27 +92,6 @@ class SharedDB { 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 { diff --git a/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart index 3a764d24..1054a1df 100644 --- a/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart +++ b/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart @@ -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/stream_chat_persistence.dart'; /// A Helper class to construct new instances of [MoorChatDatabase] class SharedDB { - /// Returns a new instance of database. - /// - /// Generally used with [ConnectionMode.regular]. - static Future constructDatabase( + /// Returns a new instance of [MoorChatDatabase]. + static MoorChatDatabase constructDatabase( String userId, { bool logStatements = false, - bool persistOnDisk = true, + ConnectionMode connectionMode = ConnectionMode.regular, }) { throw UnsupportedError( - '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'); + 'No implementation of the constructDatabase api provided', + ); } } diff --git a/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart index 32b108ba..bb664443 100644 --- a/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart +++ b/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor_web.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 /// for Web applications class SharedDB { - /// Returns a new instance of [WebDatabase] created using [userId]. - /// - /// Generally used with [ConnectionMode.regular]. - static Future 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( + /// Returns a new instance of [MoorChatDatabase]. + static MoorChatDatabase constructDatabase( String userId, { bool logStatements = false, + ConnectionMode connectionMode = ConnectionMode.regular, // Ignored on web }) { final dbName = 'db_$userId'; - return MoorChatDatabase(dbName, logStatements: logStatements); + final queryExecutor = WebDatabase(dbName, logStatements: logStatements); + return MoorChatDatabase(userId, queryExecutor); } } diff --git a/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart index 301e3592..61985d26 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; /// Represents a [ChannelQueries] table in [MoorChatDatabase]. diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart index 7e32906e..6f60fac3 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channels.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart index 7caa0407..69538edc 100644 --- a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart +++ b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 9eafd4aa..c7316640 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; /// Represents a [Members] table in [MoorChatDatabase]. diff --git a/packages/stream_chat_persistence/lib/src/entity/messages.dart b/packages/stream_chat_persistence/lib/src/entity/messages.dart index 0350d9de..8840d85f 100644 --- a/packages/stream_chat_persistence/lib/src/entity/messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/messages.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/converter/list_converter.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/pinned_messages.dart b/packages/stream_chat_persistence/lib/src/entity/pinned_messages.dart index de081552..043f69c5 100644 --- a/packages/stream_chat_persistence/lib/src/entity/pinned_messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/pinned_messages.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/entity/messages.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/reactions.dart b/packages/stream_chat_persistence/lib/src/entity/reactions.dart index b7e6342f..53cbdd11 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reactions.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reactions.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/reads.dart b/packages/stream_chat_persistence/lib/src/entity/reads.dart index 4b9ce6fb..7943ed9e 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reads.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reads.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; /// Represents a [Reads] table in [MoorChatDatabase]. diff --git a/packages/stream_chat_persistence/lib/src/entity/users.dart b/packages/stream_chat_persistence/lib/src/entity/users.dart index 4916ad28..7ece9600 100644 --- a/packages/stream_chat_persistence/lib/src/entity/users.dart +++ b/packages/stream_chat_persistence/lib/src/entity/users.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:moor/moor.dart'; import 'package:stream_chat_persistence/src/converter/map_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart index 5c8e3e2a..65315519 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart @@ -46,14 +46,14 @@ extension ChannelModelX on ChannelModel { id: id, type: type, cid: cid, - config: config.toJson(), + config: config?.toJson(), frozen: frozen, lastMessageAt: lastMessageAt, createdAt: createdAt, updatedAt: updatedAt, deletedAt: deletedAt, memberCount: memberCount, - createdById: createdBy.id, + createdById: createdBy?.id, extraData: extraData, ); } diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index e7ee060b..2c9d28fb 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -4,7 +4,6 @@ import 'package:mutex/mutex.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/shared/shared_db.dart'; /// Various connection modes on which [StreamChatPersistenceClient] can work enum ConnectionMode { @@ -15,6 +14,9 @@ enum ConnectionMode { background, } +/// Signature for a function which provides instance of [MoorChatDatabase] +typedef DatabaseProvider = MoorChatDatabase Function(String, ConnectionMode); + final _levelEmojiMapper = { Level.INFO: 'ℹ️', Level.WARNING: '⚠️', @@ -36,26 +38,6 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { _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. @visibleForTesting MoorChatDatabase db; @@ -84,24 +66,25 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { return ret; } + MoorChatDatabase _defaultDatabaseProvider( + String userId, + ConnectionMode mode, + ) => + SharedDB.constructDatabase(userId, connectionMode: mode); + @override - Future connect(String userId) async { + Future connect( + String userId, { + DatabaseProvider databaseProvider, // Used only for testing + }) async { if (db != null) { throw Exception( 'An instance of StreamChatDatabase is already connected.\n' 'disconnect the previous instance before connecting again.', ); } - switch (_connectionMode) { - case ConnectionMode.regular: - _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; - } + db = databaseProvider?.call(userId, _connectionMode) ?? + _defaultDatabaseProvider(userId, _connectionMode); } @override @@ -259,9 +242,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { @override Future updateChannelQueries( Map filter, - List cids, - bool clearQueryCache, - ) => + List cids, { + bool clearQueryCache = false, + }) => _readProtected(() async { _logger.info('updateChannelQueries'); return db.channelQueryDao.updateChannelQueries( diff --git a/packages/stream_chat_persistence/moor_schemas/moor_schema_v1.json b/packages/stream_chat_persistence/moor_schemas/moor_schema_v1.json new file mode 100644 index 00000000..1a1b39a9 --- /dev/null +++ b/packages/stream_chat_persistence/moor_schemas/moor_schema_v1.json @@ -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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + } + ], + "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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + } + ], + "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()", + "dart_type_name": "Map" + } + } + ], + "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()", + "dart_type_name": "Map" + } + } + ], + "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()", + "dart_type_name": "Map" + } + }, + { + "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" + ] + } + } + ] +} \ No newline at end of file diff --git a/packages/stream_chat_persistence/moor_schemas/moor_schema_v2.json b/packages/stream_chat_persistence/moor_schemas/moor_schema_v2.json new file mode 100644 index 00000000..0f1caee5 --- /dev/null +++ b/packages/stream_chat_persistence/moor_schemas/moor_schema_v2.json @@ -0,0 +1,1089 @@ +{ + "_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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + } + ], + "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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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": "pinned", + "moor_type": "ColumnType.boolean", + "nullable": false, + "customConstraints": null, + "default_dart": "const Constant(false)", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pinned_at", + "moor_type": "ColumnType.datetime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pin_expires", + "moor_type": "ColumnType.datetime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pinned_by_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()", + "dart_type_name": "Map" + } + } + ], + "is_virtual": false, + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "pinned_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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "List" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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()", + "dart_type_name": "Map" + } + }, + { + "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": "pinned", + "moor_type": "ColumnType.boolean", + "nullable": false, + "customConstraints": null, + "default_dart": "const Constant(false)", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pinned_at", + "moor_type": "ColumnType.datetime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pin_expires", + "moor_type": "ColumnType.datetime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pinned_by_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()", + "dart_type_name": "Map" + } + } + ], + "is_virtual": false, + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "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()", + "dart_type_name": "Map" + } + } + ], + "is_virtual": false, + "explicit_pk": [ + "message_id", + "type", + "user_id" + ] + } + }, + { + "id": 4, + "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()", + "dart_type_name": "Map" + } + } + ], + "is_virtual": false, + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "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": 6, + "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": 7, + "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": 8, + "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()", + "dart_type_name": "Map" + } + }, + { + "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" + ] + } + } + ] +} \ No newline at end of file diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 12d117b1..5423f050 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 1.5.0 +version: 1.5.1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -18,10 +18,10 @@ dependencies: path: ^1.7.0 path_provider: ^1.6.27 sqlite3_flutter_libs: ^0.4.0+1 - stream_chat: ^1.5.0 + stream_chat: ^1.5.1 dev_dependencies: build_runner: ^1.11.0 + mockito: ^4.1.3 moor_generator: ^3.4.1 - pedantic: ^1.9.2 test: ^1.15.7 diff --git a/packages/stream_chat_persistence/test/mock_chat_database.dart b/packages/stream_chat_persistence/test/mock_chat_database.dart new file mode 100644 index 00000000..a60bd39b --- /dev/null +++ b/packages/stream_chat_persistence/test/mock_chat_database.dart @@ -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 {} diff --git a/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart b/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart new file mode 100644 index 00000000..a5bedd40 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart @@ -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(); + + 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()), + ); + }); + + 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()), + ); + }); + + 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(); + + 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)); + }); + }); +} diff --git a/packages/stream_chat_persistence/test/src/converter/map_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/map_converter_test.dart new file mode 100644 index 00000000..4727cb0d --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/map_converter_test.dart @@ -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(); + + 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()), + ); + }); + + 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()), + ); + }, + ); + + 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(); + + 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)); + }); + }); +} diff --git a/packages/stream_chat_persistence/test/src/converter/message_sending_status_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/message_sending_status_converter_test.dart new file mode 100644 index 00000000..1035a759 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/message_sending_status_converter_test.dart @@ -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); + }); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart new file mode 100644 index 00000000..47c655a8 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart @@ -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(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart new file mode 100644 index 00000000..4566ca94 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -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> _insertTestDataForGetChannel( + Map 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(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart new file mode 100644 index 00000000..2969751b --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart @@ -0,0 +1,114 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/connection_event_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() { + ConnectionEventDao eventDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + eventDao = database.connectionEventDao; + }); + + test('connectionEvent', () async { + // Should be null initially + final event = await eventDao.connectionEvent; + expect(event, isNull); + + // Adding a new event + final newEvent = Event( + createdAt: DateTime.now(), + totalUnreadCount: 33, + unreadChannels: 3, + me: OwnUser(id: 'testUserId'), + ); + await eventDao.updateConnectionEvent(newEvent); + + // Should match the added event + final updatedEvent = await eventDao.connectionEvent; + expect(updatedEvent.me.id, newEvent.me.id); + expect(updatedEvent.totalUnreadCount, newEvent.totalUnreadCount); + expect(updatedEvent.unreadChannels, newEvent.unreadChannels); + }); + + test('lastSyncAt', () async { + // Should be null initially + final lastSyncAt = await eventDao.lastSyncAt; + expect(lastSyncAt, isNull); + + // Adding an event for testing + final event = Event( + createdAt: DateTime.now(), + totalUnreadCount: 33, + unreadChannels: 3, + me: OwnUser(id: 'testUserId'), + ); + await eventDao.updateConnectionEvent(event); + + // Updating it's last sync + final now = DateTime.now(); + await eventDao.updateLastSyncAt(now); + + // Should match the updated last sync + final updatedLastSyncAt = await eventDao.lastSyncAt; + expect(updatedLastSyncAt, isSameDateAs(now)); + }); + + test('updateConnectionEvent', () async { + // Adding and event for testing + final event = Event( + createdAt: DateTime.now(), + totalUnreadCount: 33, + unreadChannels: 3, + me: OwnUser(id: 'testUserId'), + ); + await eventDao.updateConnectionEvent(event); + + // Should match the previously added event + final fetchedEvent = await eventDao.connectionEvent; + expect(fetchedEvent.me.id, event.me.id); + expect(fetchedEvent.totalUnreadCount, event.totalUnreadCount); + expect(fetchedEvent.unreadChannels, event.unreadChannels); + + // Updating the added event + final newEvent = event.copyWith(unreadChannels: 4); + await eventDao.updateConnectionEvent(newEvent); + + // Should match the updated event + final fetchedNewEvent = await eventDao.connectionEvent; + expect(fetchedNewEvent.me.id, event.me.id); + expect(fetchedNewEvent.totalUnreadCount, event.totalUnreadCount); + expect(fetchedNewEvent.unreadChannels, newEvent.unreadChannels); + }); + + test('updateLastSyncAt', () async { + // Should be null initially + final lastSyncAt = await eventDao.lastSyncAt; + expect(lastSyncAt, isNull); + + // Adding an event just for testing + final event = Event( + createdAt: DateTime.now(), + totalUnreadCount: 33, + unreadChannels: 3, + me: OwnUser(id: 'testUserId'), + ); + await eventDao.updateConnectionEvent(event); + + // Updating it's last sync + final now = DateTime.now(); + await eventDao.updateLastSyncAt(now); + + // Should match the last sync + final updatedLastSyncAt = await eventDao.lastSyncAt; + expect(updatedLastSyncAt, isSameDateAs(now)); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart new file mode 100644 index 00000000..dbb8c73c --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -0,0 +1,152 @@ +import 'dart:math' as math; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/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() { + MemberDao memberDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + memberDao = database.memberDao; + }); + + Future> _prepareTestData(String cid) async { + final users = List.generate(3, (index) => User(id: 'testUserId$index')); + final memberList = List.generate( + 3, + (index) => Member( + user: users[index], + banned: math.Random().nextBool(), + shadowBanned: math.Random().nextBool(), + createdAt: DateTime.now(), + isModerator: math.Random().nextBool(), + invited: math.Random().nextBool(), + inviteAcceptedAt: DateTime.now(), + role: 'testRole', + updatedAt: DateTime.now(), + ), + ); + await database.userDao.updateUsers(users); + await memberDao.updateMembers(cid, memberList); + return memberList; + } + + test('getMembersByCid', () async { + const cid = 'testCid'; + + // Should be empty initially + final members = await memberDao.getMembersByCid(cid); + expect(members, isEmpty); + + // Preparing test data + final memberList = await _prepareTestData(cid); + + // Should match the previous test data + final fetchedMembers = await memberDao.getMembersByCid(cid); + expect(fetchedMembers.length, memberList.length); + for (var i = 0; i < fetchedMembers.length; i++) { + final member = memberList[i]; + final fetchedMember = fetchedMembers[i]; + expect(fetchedMember.user.id, member.user.id); + expect(fetchedMember.banned, member.banned); + expect(fetchedMember.shadowBanned, member.shadowBanned); + expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); + expect(fetchedMember.isModerator, member.isModerator); + expect(fetchedMember.invited, member.invited); + expect(fetchedMember.role, member.role); + expect(fetchedMember.updatedAt, isSameDateAs(member.updatedAt)); + expect( + fetchedMember.inviteAcceptedAt, + isSameDateAs(member.inviteAcceptedAt), + ); + } + }); + + test('updateMembers', () async { + const cid = 'testCid'; + + // Preparing test data + final memberList = await _prepareTestData(cid); + + // Should match the previous test data + final fetchedMembers = await memberDao.getMembersByCid(cid); + expect(fetchedMembers.length, memberList.length); + for (var i = 0; i < fetchedMembers.length; i++) { + final member = memberList[i]; + final fetchedMember = fetchedMembers[i]; + expect(fetchedMember.user.id, member.user.id); + expect(fetchedMember.banned, member.banned); + expect(fetchedMember.shadowBanned, member.shadowBanned); + expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); + expect(fetchedMember.isModerator, member.isModerator); + expect(fetchedMember.invited, member.invited); + expect(fetchedMember.role, member.role); + expect(fetchedMember.updatedAt, isSameDateAs(member.updatedAt)); + expect( + fetchedMember.inviteAcceptedAt, + isSameDateAs(member.inviteAcceptedAt), + ); + } + + // Modifying one of the member and also adding one new + final copyMember = fetchedMembers.first.copyWith(banned: true); + final newUser = User(id: 'testUserId3'); + final newMember = Member( + user: newUser, + banned: math.Random().nextBool(), + shadowBanned: math.Random().nextBool(), + createdAt: DateTime.now(), + isModerator: math.Random().nextBool(), + invited: math.Random().nextBool(), + inviteAcceptedAt: DateTime.now(), + role: 'testRole', + updatedAt: DateTime.now(), + ); + await database.userDao.updateUsers([newUser]); + await memberDao.updateMembers(cid, [copyMember, newMember]); + + // Fetched member length should be one more than inserted members. + // copyMember `banned` modified field should be true. + // Fetched members should contain the newMember. + final newFetchedMembers = await memberDao.getMembersByCid(cid); + expect(newFetchedMembers.length, fetchedMembers.length + 1); + expect( + newFetchedMembers + .firstWhere((it) => it.user.id == copyMember.user.id) + .banned, + true, + ); + expect( + newFetchedMembers + .where((it) => it.user.id == newMember.user.id) + .isNotEmpty, + true, + ); + }); + + test('deleteMemberByCids', () async { + const cid = 'testCid'; + + // Preparing test data + final members = await _prepareTestData(cid); + final fetchedMembers = await memberDao.getMembersByCid(cid); + expect(members.length, fetchedMembers.length); + + // Deleting all the members + await memberDao.deleteMemberByCids([cid]); + + // Fetched member list should be empty + final newFetchedMembers = await memberDao.getMembersByCid(cid); + expect(newFetchedMembers, isEmpty); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart new file mode 100644 index 00000000..f2440ae1 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart @@ -0,0 +1,362 @@ +import 'dart:math' as math; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/dao.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:test/test.dart'; + +void main() { + MessageDao messageDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + messageDao = database.messageDao; + }); + + Future> _prepareTestData( + String cid, { + bool quoted = false, + bool threads = false, + bool mapAllThreadToFirstMessage = false, + int count = 3, + }) async { + final users = List.generate(count, (index) => User(id: 'testUserId$index')); + final messages = List.generate( + count, + (index) => Message( + id: 'testMessageId$cid$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final quotedMessages = List.generate( + count, + (index) => Message( + id: 'testQuotedMessageId$cid$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + quotedMessageId: messages[index].id, + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final threadMessages = List.generate( + count, + (index) => Message( + id: 'testThreadMessageId$cid$index', + type: 'testType', + user: users[index], + parentId: + mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final allMessages = [ + ...messages, + if (quoted) ...quotedMessages, + if (threads) ...threadMessages + ]; + await database.userDao.updateUsers(users); + await messageDao.updateMessages(cid, allMessages); + return allMessages; + } + + test('deleteMessageByIds', () async { + const cid = 'testCid'; + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + + // Fetched message list should match the test message list length + final messages = await messageDao.getMessagesByCid(cid); + expect(messages.length, insertedMessages.length); + + // Deleting 2 messages from DB + await messageDao.deleteMessageByIds( + ['testMessageId${cid}0', 'testMessageId${cid}1'], + ); + + // New fetched messages length should 2 less than the + // previous fetched messages + final newMessages = await messageDao.getMessagesByCid(cid); + expect(newMessages.length, messages.length - 2); + }); + + group('deleteMessageByCids', () { + const cid1 = 'testCid1'; + const cid2 = 'testCid2'; + + test('should delete all the messages of first channel', () async { + // Preparing test data + final cid1InsertedMessages = await _prepareTestData(cid1); + final cid2InsertedMessages = await _prepareTestData(cid2); + + // Fetched message list should match the test message list length + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid2Messages = await messageDao.getMessagesByCid(cid2); + expect(cid1Messages.length, cid1InsertedMessages.length); + expect(cid2Messages.length, cid2InsertedMessages.length); + + // Deleting all the messages of cid1 + await messageDao.deleteMessageByCids([cid1]); + + // Fetched messages length of only cid1 should be empty + final cid1FetchedMessages = await messageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await messageDao.getMessagesByCid(cid2); + expect(cid1FetchedMessages, isEmpty); + expect(cid2FetchedMessages, isNotEmpty); + }); + + test('should delete all the messages of both channel', () async { + // Preparing test data + final cid1InsertedMessages = await _prepareTestData(cid1); + final cid2InsertedMessages = await _prepareTestData(cid2); + + // Fetched message list should match the test message list length + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid2Messages = await messageDao.getMessagesByCid(cid2); + expect(cid1Messages.length, cid1InsertedMessages.length); + expect(cid2Messages.length, cid2InsertedMessages.length); + + // Deleting all the messages of cid1 + await messageDao.deleteMessageByCids([cid1, cid2]); + + // Fetched messages length of both cid1 and cid2 should be empty + final cid1FetchedMessages = await messageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await messageDao.getMessagesByCid(cid2); + expect(cid1FetchedMessages, isEmpty); + expect(cid2FetchedMessages, isEmpty); + }); + }); + + test('getMessageById', () async { + const cid = 'testCid'; + const id = 'testMessageId${cid}0'; + + // Should be null initially + final message = await messageDao.getMessageById(id); + expect(message, isNull); + + // Adding test message with the cid and id + final insertedMessages = await _prepareTestData(cid, count: 1); + expect(insertedMessages.first.id, id); + + // Fetched message id should match the inserted message id + final fetchedMessage = await messageDao.getMessageById(id); + expect(fetchedMessage.id, insertedMessages.first.id); + }); + + test('getThreadMessages', () async { + const cid = 'testCid'; + + // Messages should be empty initially + final messages = await messageDao.getThreadMessages(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, threads: true); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of cid + final threadMessages = await messageDao.getThreadMessages(cid); + expect(threadMessages, isNotEmpty); + for (final message in threadMessages) { + expect(message.parentId, isNotNull); + } + }); + + test('getThreadMessagesByParentId', () async { + const cid = 'testCid'; + const parentId = 'testMessageId${cid}0'; + + // Messages should be empty initially + final messages = await messageDao.getThreadMessagesByParentId(parentId); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, threads: true); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of parentId + final threadMessages = + await messageDao.getThreadMessagesByParentId(parentId); + expect(threadMessages.length, 1); + expect(threadMessages.first.parentId, parentId); + }); + + test('getThreadMessagesByParentId along with pagination', () async { + const cid = 'testCid'; + const parentId = 'testMessageId${cid}0'; + const options = PaginationParams( + limit: 15, + lessThan: 'testThreadMessageId${cid}25', + greaterThanOrEqual: 'testThreadMessageId${cid}5', + ); + + // Messages should be empty initially + final messages = await messageDao.getThreadMessagesByParentId( + parentId, + options: options, + ); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData( + cid, + threads: true, + mapAllThreadToFirstMessage: true, + count: 30, + ); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of parentId and apply the pagination + final threadMessages = await messageDao.getThreadMessagesByParentId( + parentId, + options: options, + ); + expect(threadMessages.length, 15); + expect(threadMessages.first.parentId, parentId); + }); + + test('getMessagesByCid', () async { + const cid = 'testCid'; + + // Should be empty initially + final messages = await messageDao.getMessagesByCid(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await messageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length); + for (var i = 0; i < fetchedMessages.length; i++) { + final fetchedMessage = fetchedMessages[i]; + final insertedMessage = insertedMessages[i]; + expect(fetchedMessage.id, insertedMessage.id); + } + }); + + test('getMessagesByCid along with quotedMessage', () async { + const cid = 'testCid'; + + // Should be empty initially + final messages = await messageDao.getMessagesByCid(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, quoted: true); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await messageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length); + final quoted = fetchedMessages.where((it) => it.quotedMessage != null); + expect(quoted.length, insertedMessages.length / 2); + }); + + test('getMessagesByCid along with pagination', () async { + const cid = 'testCid'; + const limit = 15; + const lessThan = 'testMessageId${cid}25'; + const greaterThanOrEqual = 'testMessageId${cid}5'; + const pagination = PaginationParams( + limit: limit, + lessThan: lessThan, + greaterThanOrEqual: greaterThanOrEqual, + ); + + // Should be empty initially + final messages = await messageDao.getMessagesByCid( + cid, + messagePagination: pagination, + ); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, count: 30); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await messageDao.getMessagesByCid( + cid, + messagePagination: pagination, + ); + expect(fetchedMessages.length, limit); + expect(fetchedMessages.first.id, greaterThanOrEqual); + expect(fetchedMessages.last.id != lessThan, true); + }); + + test('updateMessages', () async { + const cid = 'testCid'; + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + expect(insertedMessages, isNotEmpty); + + // Modifying one of the message and also adding one new + final copyMessage = insertedMessages.first.copyWith(showInChannel: false); + final newMessage = Message( + id: 'testMessageId${cid}4', + type: 'testType', + user: User(id: 'testUserId4'), + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 4, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #4', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId4'), + ); + + await messageDao.updateMessages(cid, [copyMessage, newMessage]); + + // Fetched messages length should be one more than inserted message. + // copyMessage `showInChannel` modified field should be false. + // Fetched messages should contain the newMessage. + final fetchedMessages = await messageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length + 1); + expect( + fetchedMessages.firstWhere((it) => it.id == copyMessage.id).showInChannel, + false, + ); + expect( + fetchedMessages.map((it) => it.id).contains(newMessage.id), + true, + ); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart new file mode 100644 index 00000000..de7c8976 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart @@ -0,0 +1,363 @@ +import 'dart:math' as math; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/dao.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:test/test.dart'; + +void main() { + PinnedMessageDao pinnedMessageDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + pinnedMessageDao = database.pinnedMessageDao; + }); + + Future> _prepareTestData( + String cid, { + bool quoted = false, + bool threads = false, + bool mapAllThreadToFirstMessage = false, + int count = 3, + }) async { + final users = List.generate(count, (index) => User(id: 'testUserId$index')); + final messages = List.generate( + count, + (index) => Message( + id: 'testMessageId$cid$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final quotedMessages = List.generate( + count, + (index) => Message( + id: 'testQuotedMessageId$cid$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + quotedMessageId: messages[index].id, + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final threadMessages = List.generate( + count, + (index) => Message( + id: 'testThreadMessageId$cid$index', + type: 'testType', + user: users[index], + parentId: + mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ), + ); + final allMessages = [ + ...messages, + if (quoted) ...quotedMessages, + if (threads) ...threadMessages + ]; + await database.userDao.updateUsers(users); + await pinnedMessageDao.updateMessages(cid, allMessages); + return allMessages; + } + + test('deleteMessageByIds', () async { + const cid = 'testCid'; + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + + // Fetched message list should match the test message list length + final messages = await pinnedMessageDao.getMessagesByCid(cid); + expect(messages.length, insertedMessages.length); + + // Deleting 2 messages from DB + await pinnedMessageDao.deleteMessageByIds( + ['testMessageId${cid}0', 'testMessageId${cid}1'], + ); + + // New fetched messages length should 2 less than the + // previous fetched messages + final newMessages = await pinnedMessageDao.getMessagesByCid(cid); + expect(newMessages.length, messages.length - 2); + }); + + group('deleteMessageByCids', () { + const cid1 = 'testCid1'; + const cid2 = 'testCid2'; + + test('should delete all the messages of first channel', () async { + // Preparing test data + final cid1InsertedMessages = await _prepareTestData(cid1); + final cid2InsertedMessages = await _prepareTestData(cid2); + + // Fetched message list should match the test message list length + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2Messages = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid1Messages.length, cid1InsertedMessages.length); + expect(cid2Messages.length, cid2InsertedMessages.length); + + // Deleting all the messages of cid1 + await pinnedMessageDao.deleteMessageByCids([cid1]); + + // Fetched messages length of only cid1 should be empty + final cid1FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid1FetchedMessages, isEmpty); + expect(cid2FetchedMessages, isNotEmpty); + }); + + test('should delete all the messages of both channel', () async { + // Preparing test data + final cid1InsertedMessages = await _prepareTestData(cid1); + final cid2InsertedMessages = await _prepareTestData(cid2); + + // Fetched message list should match the test message list length + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2Messages = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid1Messages.length, cid1InsertedMessages.length); + expect(cid2Messages.length, cid2InsertedMessages.length); + + // Deleting all the messages of cid1 + await pinnedMessageDao.deleteMessageByCids([cid1, cid2]); + + // Fetched messages length of both cid1 and cid2 should be empty + final cid1FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid1FetchedMessages, isEmpty); + expect(cid2FetchedMessages, isEmpty); + }); + }); + + test('getMessageById', () async { + const cid = 'testCid'; + const id = 'testMessageId${cid}0'; + + // Should be null initially + final message = await pinnedMessageDao.getMessageById(id); + expect(message, isNull); + + // Adding test message with the cid and id + final insertedMessages = await _prepareTestData(cid, count: 1); + expect(insertedMessages.first.id, id); + + // Fetched message id should match the inserted message id + final fetchedMessage = await pinnedMessageDao.getMessageById(id); + expect(fetchedMessage.id, insertedMessages.first.id); + }); + + test('getThreadMessages', () async { + const cid = 'testCid'; + + // Messages should be empty initially + final messages = await pinnedMessageDao.getThreadMessages(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, threads: true); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of cid + final threadMessages = await pinnedMessageDao.getThreadMessages(cid); + expect(threadMessages, isNotEmpty); + for (final message in threadMessages) { + expect(message.parentId, isNotNull); + } + }); + + test('getThreadMessagesByParentId', () async { + const cid = 'testCid'; + const parentId = 'testMessageId${cid}0'; + + // Messages should be empty initially + final messages = + await pinnedMessageDao.getThreadMessagesByParentId(parentId); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, threads: true); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of parentId + final threadMessages = + await pinnedMessageDao.getThreadMessagesByParentId(parentId); + expect(threadMessages.length, 1); + expect(threadMessages.first.parentId, parentId); + }); + + test('getThreadMessagesByParentId along with pagination', () async { + const cid = 'testCid'; + const parentId = 'testMessageId${cid}0'; + const options = PaginationParams( + limit: 15, + lessThan: 'testThreadMessageId${cid}25', + greaterThanOrEqual: 'testThreadMessageId${cid}5', + ); + + // Messages should be empty initially + final messages = await pinnedMessageDao.getThreadMessagesByParentId( + parentId, + options: options, + ); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData( + cid, + threads: true, + mapAllThreadToFirstMessage: true, + count: 30, + ); + expect(insertedMessages, isNotEmpty); + + // Should fetch all the thread messages of parentId and apply the pagination + final threadMessages = await pinnedMessageDao.getThreadMessagesByParentId( + parentId, + options: options, + ); + expect(threadMessages.length, 15); + expect(threadMessages.first.parentId, parentId); + }); + + test('getMessagesByCid', () async { + const cid = 'testCid'; + + // Should be empty initially + final messages = await pinnedMessageDao.getMessagesByCid(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await pinnedMessageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length); + for (var i = 0; i < fetchedMessages.length; i++) { + final fetchedMessage = fetchedMessages[i]; + final insertedMessage = insertedMessages[i]; + expect(fetchedMessage.id, insertedMessage.id); + } + }); + + test('getMessagesByCid along with quotedMessage', () async { + const cid = 'testCid'; + + // Should be empty initially + final messages = await pinnedMessageDao.getMessagesByCid(cid); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, quoted: true); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await pinnedMessageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length); + final quoted = fetchedMessages.where((it) => it.quotedMessage != null); + expect(quoted.length, insertedMessages.length / 2); + }); + + test('getMessagesByCid along with pagination', () async { + const cid = 'testCid'; + const limit = 15; + const lessThan = 'testMessageId${cid}25'; + const greaterThanOrEqual = 'testMessageId${cid}5'; + const pagination = PaginationParams( + limit: limit, + lessThan: lessThan, + greaterThanOrEqual: greaterThanOrEqual, + ); + + // Should be empty initially + final messages = await pinnedMessageDao.getMessagesByCid( + cid, + messagePagination: pagination, + ); + expect(messages, isEmpty); + + // Preparing test data + final insertedMessages = await _prepareTestData(cid, count: 30); + expect(insertedMessages, isNotEmpty); + + // Fetched message should match the inserted messages + final fetchedMessages = await pinnedMessageDao.getMessagesByCid( + cid, + messagePagination: pagination, + ); + expect(fetchedMessages.length, limit); + expect(fetchedMessages.first.id, greaterThanOrEqual); + expect(fetchedMessages.last.id != lessThan, true); + }); + + test('updateMessages', () async { + const cid = 'testCid'; + + // Preparing test data + final insertedMessages = await _prepareTestData(cid); + expect(insertedMessages, isNotEmpty); + + // Modifying one of the message and also adding one new + final copyMessage = insertedMessages.first.copyWith(showInChannel: false); + final newMessage = Message( + id: 'testMessageId${cid}4', + type: 'testType', + user: User(id: 'testUserId4'), + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 4, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #4', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId4'), + ); + + await pinnedMessageDao.updateMessages(cid, [copyMessage, newMessage]); + + // Fetched messages length should be one more than inserted message. + // copyMessage `showInChannel` modified field should be false. + // Fetched messages should contain the newMessage. + final fetchedMessages = await pinnedMessageDao.getMessagesByCid(cid); + expect(fetchedMessages.length, insertedMessages.length + 1); + expect( + fetchedMessages.firstWhere((it) => it.id == copyMessage.id).showInChannel, + false, + ); + expect( + fetchedMessages.map((it) => it.id).contains(newMessage.id), + true, + ); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart new file mode 100644 index 00000000..94f8ebdd --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart @@ -0,0 +1,188 @@ +import 'dart:math' as math; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/reaction_dao.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:test/test.dart'; + +void main() { + ReactionDao reactionDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + reactionDao = database.reactionDao; + }); + + Future> _prepareReactionData( + String messageId, { + String userId, + int count = 3, + }) async { + final users = List.generate(count, (index) => User(id: 'testUserId$index')); + final message = Message( + id: messageId, + type: 'testType', + user: users.first, + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 3, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text', + pinned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + pinnedBy: users.first, + ); + final reactions = List.generate( + count, + (index) => Reaction( + type: 'testType$index', + createdAt: DateTime.now(), + userId: userId ?? users[index].id, + messageId: message.id, + score: count + 3, + extraData: {'extra_test_field': 'extraTestData'}, + ), + ); + + await database.userDao.updateUsers(users); + await database.messageDao.updateMessages('testCid', [message]); + await reactionDao.updateReactions(reactions); + + return reactions; + } + + test('getReactions', () async { + const messageId = 'testMessageId'; + + // Should be empty initially + final reactions = await reactionDao.getReactions(messageId); + expect(reactions, isEmpty); + + // Adding sample reactions + final insertedReactions = await _prepareReactionData(messageId); + expect(insertedReactions, isNotEmpty); + + // Fetched reaction length should match inserted reactions length. + // Every reaction messageId should match the provided messageId. + final fetchedReactions = await reactionDao.getReactions(messageId); + expect(fetchedReactions.length, insertedReactions.length); + expect(fetchedReactions.every((it) => it.messageId == messageId), true); + }); + + test('getReactionsByUserId', () async { + const messageId = 'testMessageId'; + const userId = 'testUserId'; + + // Should be empty initially + final reactions = await reactionDao.getReactionsByUserId(messageId, userId); + expect(reactions, isEmpty); + + // Adding sample reactions + final insertedReactions = + await _prepareReactionData(messageId, userId: userId); + expect(insertedReactions, isNotEmpty); + + // Fetched reaction length should match inserted reactions length. + // Every reaction messageId should match the provided messageId. + // Every reaction userId should match the provided userId. + final fetchedReactions = + await reactionDao.getReactionsByUserId(messageId, userId); + expect(fetchedReactions.length, insertedReactions.length); + expect(fetchedReactions.every((it) => it.messageId == messageId), true); + expect(fetchedReactions.every((it) => it.userId == userId), true); + }); + + test('updateReactions', () async { + const messageId = 'testMessageId'; + + // Preparing test data + final reactions = await _prepareReactionData(messageId); + + // Modifying one of the reaction and also adding one new + final copyReaction = reactions.first.copyWith(score: 33); + final newReaction = Reaction( + type: 'testType3', + createdAt: DateTime.now(), + userId: 'testUserId3', + messageId: messageId, + score: 30, + extraData: {'extra_test_field': 'extraTestData'}, + ); + + await reactionDao.updateReactions([copyReaction, newReaction]); + + // Fetched reaction length should be one more than inserted reactions. + // copyReaction `score` modified field should be 33. + // Fetched reactions should contain the newReaction. + final fetchedReactions = await reactionDao.getReactions(messageId); + expect(fetchedReactions.length, reactions.length + 1); + expect( + fetchedReactions + .firstWhere((it) => + it.userId == copyReaction.userId && it.type == copyReaction.type) + .score, + 33, + ); + expect( + fetchedReactions + .where((it) => + it.userId == newReaction.userId && it.type == newReaction.type) + .isNotEmpty, + true, + ); + }); + + group('deleteReactionsByMessageIds', () { + const messageId1 = 'testMessageId1'; + const messageId2 = 'testMessageId2'; + test('should delete all the reactions of first message', () async { + // Preparing test data + final insertedReactions1 = await _prepareReactionData(messageId1); + final insertedReactions2 = await _prepareReactionData(messageId2); + + // Fetched reaction list length should match + // the inserted reactions list length + final reactions1 = await reactionDao.getReactions(messageId1); + final reactions2 = await reactionDao.getReactions(messageId2); + expect(reactions1.length, insertedReactions1.length); + expect(reactions2.length, insertedReactions2.length); + + // Deleting all the reactions of messageId1 + await reactionDao.deleteReactionsByMessageIds([messageId1]); + + // Fetched reactions length of only messageId1 should be empty + final fetchedReactions1 = await reactionDao.getReactions(messageId1); + final fetchedReactions2 = await reactionDao.getReactions(messageId2); + expect(fetchedReactions1, isEmpty); + expect(fetchedReactions2, isNotEmpty); + }); + test('should delete all the messages of both message', () async { + // Preparing test data + final insertedReactions1 = await _prepareReactionData(messageId1); + final insertedReactions2 = await _prepareReactionData(messageId2); + + // Fetched reaction list length should match + // the inserted reactions list length + final reactions1 = await reactionDao.getReactions(messageId1); + final reactions2 = await reactionDao.getReactions(messageId2); + expect(reactions1.length, insertedReactions1.length); + expect(reactions2.length, insertedReactions2.length); + + // Deleting all the reactions of messageId1 and messageId2 + await reactionDao.deleteReactionsByMessageIds([messageId1, messageId2]); + + // Fetched reactions length of both messages should be empty + final fetchedReactions1 = await reactionDao.getReactions(messageId1); + final fetchedReactions2 = await reactionDao.getReactions(messageId2); + expect(fetchedReactions1, isEmpty); + expect(fetchedReactions2, isEmpty); + }); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart new file mode 100644 index 00000000..52207f68 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart @@ -0,0 +1,97 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/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() { + ReadDao readDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + readDao = database.readDao; + }); + + Future> _prepareReadData(String cid, {int count = 3}) async { + final users = List.generate(count, (index) => User(id: 'testUserId$index')); + final reads = List.generate( + count, + (index) => Read( + lastRead: DateTime.now(), + user: users[index], + unreadMessages: index + 10, + ), + ); + + await database.userDao.updateUsers(users); + await readDao.updateReads(cid, reads); + return reads; + } + + test('getReadsByCid', () async { + const cid = 'testCid'; + + // Should be empty initially + final reads = await readDao.getReadsByCid(cid); + expect(reads, isEmpty); + + // Preparing test data + final insertedReads = await _prepareReadData(cid); + expect(insertedReads, isNotEmpty); + + // Fetched reads should be equal to inserted reads + final fetchedReads = await readDao.getReadsByCid(cid); + expect(fetchedReads.length, insertedReads.length); + for (var i = 0; i < fetchedReads.length; i++) { + final fetchedRead = fetchedReads[i]; + final insertedRead = insertedReads[i]; + expect(fetchedRead.user.id, insertedRead.user.id); + expect(fetchedRead.lastRead, isSameDateAs(insertedRead.lastRead)); + expect(fetchedRead.unreadMessages, insertedRead.unreadMessages); + } + }); + + test('updateReads', () async { + const cid = 'testCid'; + + // Preparing test data + final insertedReads = await _prepareReadData(cid); + + // Modifying one of the read and also adding one new + final copyRead = insertedReads.first.copyWith(unreadMessages: 33); + final newUser = User(id: 'testUserId3'); + final newRead = Read( + lastRead: DateTime.now(), + user: newUser, + unreadMessages: 30, + ); + await database.userDao.updateUsers([newUser]); + await readDao.updateReads(cid, [copyRead, newRead]); + + // Fetched reads length should be one more than inserted reads. + // copyRead `unreadMessages` modified field should be 33. + // Fetched reads should contain the newRead. + final fetchedReads = await readDao.getReadsByCid(cid); + expect(fetchedReads.length, insertedReads.length + 1); + expect( + fetchedReads + .firstWhere((it) => it.user.id == copyRead.user.id) + .unreadMessages, + 33, + ); + expect( + fetchedReads + .where((it) => + it.user.id == newRead.user.id && + it.unreadMessages == newRead.unreadMessages) + .isNotEmpty, + true, + ); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart new file mode 100644 index 00000000..8c5898a0 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:stream_chat_persistence/src/dao/dao.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:test/test.dart'; +import 'package:stream_chat/stream_chat.dart'; + +void main() { + UserDao userDao; + MoorChatDatabase database; + + setUp(() { + database = MoorChatDatabase.testable('testUserId'); + userDao = database.userDao; + }); + + Future> _prepareUserData({int count = 3}) async { + final users = List.generate( + count, + (index) => User( + id: 'testUserId$index', + role: 'testRole', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: math.Random().nextBool(), + banned: math.Random().nextBool(), + ), + ); + await userDao.updateUsers(users); + return users; + } + + test('updateUsers', () async { + // Preparing test data + final insertedUsers = await _prepareUserData(); + + // Modifying one of the user and also adding one new + final copyUser = insertedUsers.first.copyWith(online: false); + final newUser = User( + id: 'testUserId3', + role: 'testRole', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: math.Random().nextBool(), + banned: math.Random().nextBool(), + ); + await userDao.updateUsers([copyUser, newUser]); + + // Fetched users length should be one more than inserted users. + // copyUser `online` modified field should be `false`. + // Fetched users should contain the newUser. + final fetchedUsers = await userDao.getUsers(); + expect(fetchedUsers.length, insertedUsers.length + 1); + expect(fetchedUsers.firstWhere((it) => it.id == copyUser.id).online, false); + expect(fetchedUsers.contains(newUser), true); + }); + + test('getUsers', () async { + // Should be empty initially + final users = await userDao.getUsers(); + expect(users, isEmpty); + + // Preparing test data + final insertedUsers = await _prepareUserData(); + expect(insertedUsers, isNotEmpty); + + // Fetched user list should match inserted user list length + final fetchedUsers = await userDao.getUsers(); + expect(fetchedUsers.length, insertedUsers.length); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/db/moor_chat_database_test.dart b/packages/stream_chat_persistence/test/src/db/moor_chat_database_test.dart new file mode 100644 index 00000000..2ec4c76b --- /dev/null +++ b/packages/stream_chat_persistence/test/src/db/moor_chat_database_test.dart @@ -0,0 +1,43 @@ +import 'package:test/test.dart'; +import 'package:moor/ffi.dart'; +import 'package:moor/isolate.dart'; +import 'package:moor/moor.dart' hide isNotNull; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +DatabaseConnection _backgroundConnection() => + DatabaseConnection.fromExecutor(VmDatabase.memory()); + +void main() { + test( + 'default constructor should create a new instance of MoorChatDatabase', + () async { + const userId = 'testUserId'; + final executor = VmDatabase.memory(); + final database = MoorChatDatabase(userId, executor); + expect(database, isNotNull); + expect(database.userId, userId); + + addTearDown(() async { + await database.disconnect(); + }); + }, + ); + + test( + 'connect constructor should create a new instance of MoorChatDatabase', + () async { + const userId = 'testUserId'; + final isolate = await MoorIsolate.spawn(_backgroundConnection); + final connection = DatabaseConnection.delayed(isolate.connect()); + + final database = MoorChatDatabase.connect(userId, connection); + expect(database, isNotNull); + expect(database.userId, userId); + + addTearDown(() async { + await database.disconnect(); + await isolate.shutdownAll(); + }); + }, + ); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart new file mode 100644 index 00000000..dc02ee4b --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart @@ -0,0 +1,111 @@ +import 'dart:math' as math; + +import 'package:test/test.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/mapper/channel_mapper.dart'; +import '../utils/date_matcher.dart'; + +void main() { + group('ChannelEntity', () { + final user = User(id: 'testUserId'); + final entity = ChannelEntity( + id: 'testId', + type: 'testType', + cid: 'testCid', + config: {'max_message_length': 33}, + frozen: math.Random().nextBool(), + lastMessageAt: DateTime.now(), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: DateTime.now(), + memberCount: 33, + createdById: user.id, + extraData: {'test_extra_data': 'testData'}, + ); + + test('toChannelModel should map entity into ChannelModel', () { + final channelModel = entity.toChannelModel(createdBy: user); + expect(channelModel, isA()); + expect(channelModel.id, entity.id); + expect(channelModel.config.toJson()['max_message_length'], 33); + expect(channelModel.frozen, entity.frozen); + expect(channelModel.createdAt, isSameDateAs(entity.createdAt)); + expect(channelModel.updatedAt, isSameDateAs(entity.updatedAt)); + expect(channelModel.memberCount, entity.memberCount); + expect(channelModel.cid, entity.cid); + expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt)); + expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); + expect(channelModel.extraData, entity.extraData); + expect(channelModel.createdBy.id, entity.createdById); + }); + + test('toChannelState should map entity into ChannelState ', () { + final members = List.generate(3, (index) => Member()); + final reads = List.generate(3, (index) => Read()); + final messages = List.generate(3, (index) => Message()); + + final channelState = entity.toChannelState( + createdBy: user, + members: members, + reads: reads, + messages: messages, + pinnedMessages: messages, + ); + + expect(channelState, isA()); + expect(channelState.members.length, members.length); + expect(channelState.read.length, reads.length); + expect(channelState.messages.length, messages.length); + expect(channelState.pinnedMessages.length, messages.length); + + final channelModel = channelState.channel; + expect(channelModel.id, entity.id); + expect(channelModel.config.toJson()['max_message_length'], 33); + expect(channelModel.frozen, entity.frozen); + expect(channelModel.createdAt, isSameDateAs(entity.createdAt)); + expect(channelModel.updatedAt, isSameDateAs(entity.updatedAt)); + expect(channelModel.memberCount, entity.memberCount); + expect(channelModel.cid, entity.cid); + expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt)); + expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); + expect(channelModel.extraData, entity.extraData); + expect(channelModel.createdBy.id, entity.createdById); + }); + }); + + test('toEntity should map model into ChannelEntity', () { + final createdBy = User(id: 'testUserId'); + final model = ChannelModel( + id: 'testId', + type: 'testType', + cid: 'testCid', + config: ChannelConfig(maxMessageLength: 33), + frozen: math.Random().nextBool(), + lastMessageAt: DateTime.now(), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: DateTime.now(), + memberCount: 33, + createdBy: createdBy, + extraData: {'test_extra_data': 'testData'}, + ); + + final channelEntity = model.toEntity(); + expect(channelEntity, isA()); + expect(channelEntity.id, model.id); + expect( + channelEntity.config['max_message_length'], + model.config.maxMessageLength, + ); + expect(channelEntity.frozen, model.frozen); + expect(channelEntity.createdAt, isSameDateAs(model.createdAt)); + expect(channelEntity.updatedAt, isSameDateAs(model.updatedAt)); + expect(channelEntity.memberCount, model.memberCount); + expect(channelEntity.cid, model.cid); + expect(channelEntity.lastMessageAt, isSameDateAs(model.lastMessageAt)); + expect(channelEntity.deletedAt, isSameDateAs(model.deletedAt)); + expect(channelEntity.extraData, model.extraData); + expect(channelEntity.createdById, model.createdBy.id); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart new file mode 100644 index 00000000..240ce1a7 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart @@ -0,0 +1,23 @@ +import 'package:test/test.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/mapper/event_mapper.dart'; + +void main() { + test('toEvent should map entity into Event', () { + final ownUser = OwnUser(id: 'testUserId'); + final entity = ConnectionEventEntity( + id: 3, + ownUser: ownUser.toJson(), + totalUnreadCount: 33, + unreadChannels: 33, + lastSyncAt: DateTime.now(), + lastEventAt: DateTime.now(), + ); + final event = entity.toEvent(); + expect(event, isA()); + expect(event.me.id, ownUser.id); + expect(event.totalUnreadCount, entity.totalUnreadCount); + expect(event.unreadChannels, entity.unreadChannels); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart new file mode 100644 index 00000000..0f7d4f11 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -0,0 +1,69 @@ +import 'dart:math' as math; + +import 'package:test/test.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/mapper/member_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toMember should map entity into Member', () { + final user = User(id: 'testUserId'); + final entity = MemberEntity( + userId: user.id, + channelCid: 'testCid', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + role: 'testRole', + inviteAcceptedAt: DateTime.now(), + inviteRejectedAt: DateTime.now(), + invited: math.Random().nextBool(), + banned: math.Random().nextBool(), + shadowBanned: math.Random().nextBool(), + isModerator: math.Random().nextBool(), + ); + final member = entity.toMember(user: user); + expect(member, isA()); + expect(member.user.id, entity.userId); + expect(member.createdAt, isSameDateAs(entity.createdAt)); + expect(member.updatedAt, isSameDateAs(entity.updatedAt)); + expect(member.role, entity.role); + expect(member.inviteAcceptedAt, isSameDateAs(entity.inviteAcceptedAt)); + expect(member.inviteRejectedAt, isSameDateAs(entity.inviteRejectedAt)); + expect(member.invited, entity.invited); + expect(member.banned, entity.banned); + expect(member.shadowBanned, entity.shadowBanned); + expect(member.isModerator, entity.isModerator); + }); + + test('toEntity show map member into MemberEntity', () { + const cid = 'testCid'; + final user = User(id: 'testUserId'); + final member = Member( + user: user, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + role: 'testRole', + inviteAcceptedAt: DateTime.now(), + inviteRejectedAt: DateTime.now(), + invited: math.Random().nextBool(), + banned: math.Random().nextBool(), + shadowBanned: math.Random().nextBool(), + isModerator: math.Random().nextBool(), + ); + final entity = member.toEntity(cid: cid); + expect(entity, isA()); + expect(entity.channelCid, cid); + expect(entity.userId, member.user.id); + expect(entity.createdAt, isSameDateAs(member.createdAt)); + expect(entity.updatedAt, isSameDateAs(member.updatedAt)); + expect(entity.role, member.role); + expect(entity.inviteAcceptedAt, isSameDateAs(member.inviteAcceptedAt)); + expect(entity.inviteRejectedAt, isSameDateAs(member.inviteRejectedAt)); + expect(entity.invited, member.invited); + expect(entity.banned, member.banned); + expect(entity.shadowBanned, member.shadowBanned); + expect(entity.isModerator, member.isModerator); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart new file mode 100644 index 00000000..21a05da8 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:test/test.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/mapper/message_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toMessage should map the entity into Message', () { + final user = User(id: 'testUserId'); + final quotedMessage = Message(id: 'testQuotedMessageId'); + final reactions = List.generate( + 3, + (index) => Reaction( + messageId: 'testMessageId', + createdAt: DateTime.now(), + type: 'testType$index', + user: user, + score: math.Random().nextInt(50), + ), + ); + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'testAttachmentId', + type: 'testAttachmentType', + assetUrl: 'testAssetUrl', + ), + ); + final entity = MessageEntity( + id: 'testMessageId', + attachments: attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + channelCid: 'testCid', + type: 'testType', + parentId: 'testParentId', + quotedMessageId: quotedMessage.id, + command: 'testCommand', + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 33, + reactionScores: {for (final r in reactions) r.type: r.score}, + reactionCounts: reactions.fold( + {}, + (prev, curr) => + prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + ), + status: MessageSendingStatus.sent, + updatedAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + userId: user.id, + deletedAt: DateTime.now(), + messageText: 'dummy text', + pinned: true, + pinExpires: DateTime.now().toUtc(), + pinnedAt: DateTime.now(), + pinnedByUserId: user.id, + ); + final message = entity.toMessage( + user: user, + pinnedBy: user, + latestReactions: reactions, + ownReactions: reactions, + quotedMessage: quotedMessage, + ); + + expect(message, isA()); + expect(message.id, entity.id); + expect(message.type, entity.type); + expect(message.parentId, entity.parentId); + expect(message.quotedMessageId, entity.quotedMessageId); + expect(message.command, entity.command); + expect(message.createdAt, isSameDateAs(entity.createdAt)); + expect(message.shadowed, entity.shadowed); + expect(message.showInChannel, entity.showInChannel); + expect(message.replyCount, entity.replyCount); + expect(message.reactionScores, entity.reactionScores); + expect(message.reactionCounts, entity.reactionCounts); + expect(message.status, entity.status); + expect(message.updatedAt, isSameDateAs(entity.updatedAt)); + expect(message.extraData, entity.extraData); + expect(message.user.id, entity.userId); + expect(message.deletedAt, isSameDateAs(entity.deletedAt)); + expect(message.text, entity.messageText); + expect(message.pinned, entity.pinned); + expect(message.pinExpires, isSameDateAs(entity.pinExpires)); + expect(message.pinnedAt, isSameDateAs(entity.pinnedAt)); + expect(message.pinnedBy.id, entity.pinnedByUserId); + expect(message.reactionCounts, entity.reactionCounts); + expect(message.reactionScores, entity.reactionScores); + for (var i = 0; i < message.attachments.length; i++) { + final messageAttachment = message.attachments[i]; + final entityAttachmentData = jsonDecode(entity.attachments[i]); + final entityAttachment = Attachment.fromData(entityAttachmentData); + expect(messageAttachment.id, entityAttachment.id); + expect(messageAttachment.type, entityAttachment.type); + expect(messageAttachment.assetUrl, entityAttachment.assetUrl); + } + }); + + test('toEntity should map message into MessageEntity', () { + const cid = 'testCid'; + final user = User(id: 'testUserId'); + final quotedMessage = Message(id: 'testQuotedMessageId'); + final reactions = List.generate( + 3, + (index) => Reaction( + messageId: 'testMessageId', + createdAt: DateTime.now(), + type: 'testType$index', + user: user, + score: math.Random().nextInt(50), + ), + ); + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'testAttachmentId', + type: 'testAttachmentType', + assetUrl: 'testAssetUrl', + ), + ); + final message = Message( + id: 'testMessageId', + attachments: attachments, + type: 'testType', + parentId: 'testParentId', + quotedMessageId: quotedMessage.id, + command: 'testCommand', + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 33, + reactionScores: {for (final r in reactions) r.type: r.score}, + reactionCounts: reactions.fold( + {}, + (prev, curr) => + prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + ), + status: MessageSendingStatus.sending, + updatedAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + user: user, + deletedAt: DateTime.now(), + text: 'dummy text', + pinned: true, + pinExpires: DateTime.now(), + pinnedAt: DateTime.now(), + pinnedBy: user, + ); + final entity = message.toEntity(cid: cid); + expect(entity, isA()); + expect(entity.id, message.id); + expect(entity.type, message.type); + expect(entity.parentId, message.parentId); + expect(entity.quotedMessageId, message.quotedMessageId); + expect(entity.command, message.command); + expect(entity.createdAt, isSameDateAs(message.createdAt)); + expect(entity.shadowed, message.shadowed); + expect(entity.showInChannel, message.showInChannel); + expect(entity.replyCount, message.replyCount); + expect(entity.reactionScores, message.reactionScores); + expect(entity.reactionCounts, message.reactionCounts); + expect(entity.status, message.status); + expect(entity.updatedAt, isSameDateAs(message.updatedAt)); + expect(entity.extraData, message.extraData); + expect(entity.userId, message.user.id); + expect(entity.deletedAt, isSameDateAs(message.deletedAt)); + expect(entity.messageText, message.text); + expect(entity.pinned, message.pinned); + expect(entity.pinExpires, isSameDateAs(message.pinExpires)); + expect(entity.pinnedAt, isSameDateAs(message.pinnedAt)); + expect(entity.pinnedByUserId, message.pinnedBy.id); + expect(entity.reactionCounts, message.reactionCounts); + expect(entity.reactionScores, message.reactionScores); + expect( + entity.attachments, + message.attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + ); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart new file mode 100644 index 00000000..0aadf08a --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:test/test.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/mapper/pinned_message_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toMessage should map the entity into Message', () { + final user = User(id: 'testUserId'); + final quotedMessage = Message(id: 'testQuotedMessageId'); + final reactions = List.generate( + 3, + (index) => Reaction( + messageId: 'testMessageId', + createdAt: DateTime.now(), + type: 'testType$index', + user: user, + score: math.Random().nextInt(50), + ), + ); + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'testAttachmentId', + type: 'testAttachmentType', + assetUrl: 'testAssetUrl', + ), + ); + final entity = PinnedMessageEntity( + id: 'testMessageId', + attachments: attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + channelCid: 'testCid', + type: 'testType', + parentId: 'testParentId', + quotedMessageId: quotedMessage.id, + command: 'testCommand', + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 33, + reactionScores: {for (final r in reactions) r.type: r.score}, + reactionCounts: reactions.fold( + {}, + (prev, curr) => + prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + ), + status: MessageSendingStatus.sent, + updatedAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + userId: user.id, + deletedAt: DateTime.now(), + messageText: 'dummy text', + pinned: true, + pinExpires: DateTime.now().toUtc(), + pinnedAt: DateTime.now(), + pinnedByUserId: user.id, + ); + final message = entity.toMessage( + user: user, + pinnedBy: user, + latestReactions: reactions, + ownReactions: reactions, + quotedMessage: quotedMessage, + ); + + expect(message, isA()); + expect(message.id, entity.id); + expect(message.type, entity.type); + expect(message.parentId, entity.parentId); + expect(message.quotedMessageId, entity.quotedMessageId); + expect(message.command, entity.command); + expect(message.createdAt, isSameDateAs(entity.createdAt)); + expect(message.shadowed, entity.shadowed); + expect(message.showInChannel, entity.showInChannel); + expect(message.replyCount, entity.replyCount); + expect(message.reactionScores, entity.reactionScores); + expect(message.reactionCounts, entity.reactionCounts); + expect(message.status, entity.status); + expect(message.updatedAt, isSameDateAs(entity.updatedAt)); + expect(message.extraData, entity.extraData); + expect(message.user.id, entity.userId); + expect(message.deletedAt, isSameDateAs(entity.deletedAt)); + expect(message.text, entity.messageText); + expect(message.pinned, entity.pinned); + expect(message.pinExpires, isSameDateAs(entity.pinExpires)); + expect(message.pinnedAt, isSameDateAs(entity.pinnedAt)); + expect(message.pinnedBy.id, entity.pinnedByUserId); + expect(message.reactionCounts, entity.reactionCounts); + expect(message.reactionScores, entity.reactionScores); + for (var i = 0; i < message.attachments.length; i++) { + final messageAttachment = message.attachments[i]; + final entityAttachmentData = jsonDecode(entity.attachments[i]); + final entityAttachment = Attachment.fromData(entityAttachmentData); + expect(messageAttachment.id, entityAttachment.id); + expect(messageAttachment.type, entityAttachment.type); + expect(messageAttachment.assetUrl, entityAttachment.assetUrl); + } + }); + + test('toPinnedEntity should map message into PinnedMessageEntity', () { + const cid = 'testCid'; + final user = User(id: 'testUserId'); + final quotedMessage = Message(id: 'testQuotedMessageId'); + final reactions = List.generate( + 3, + (index) => Reaction( + messageId: 'testMessageId', + createdAt: DateTime.now(), + type: 'testType$index', + user: user, + score: math.Random().nextInt(50), + ), + ); + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'testAttachmentId', + type: 'testAttachmentType', + assetUrl: 'testAssetUrl', + ), + ); + final message = Message( + id: 'testMessageId', + attachments: attachments, + type: 'testType', + parentId: 'testParentId', + quotedMessageId: quotedMessage.id, + command: 'testCommand', + createdAt: DateTime.now(), + shadowed: math.Random().nextBool(), + showInChannel: math.Random().nextBool(), + replyCount: 33, + reactionScores: {for (final r in reactions) r.type: r.score}, + reactionCounts: reactions.fold( + {}, + (prev, curr) => + prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + ), + status: MessageSendingStatus.sending, + updatedAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + user: user, + deletedAt: DateTime.now(), + text: 'dummy text', + pinned: true, + pinExpires: DateTime.now(), + pinnedAt: DateTime.now(), + pinnedBy: user, + ); + final entity = message.toPinnedEntity(cid: cid); + expect(entity, isA()); + expect(entity.id, message.id); + expect(entity.type, message.type); + expect(entity.parentId, message.parentId); + expect(entity.quotedMessageId, message.quotedMessageId); + expect(entity.command, message.command); + expect(entity.createdAt, isSameDateAs(message.createdAt)); + expect(entity.shadowed, message.shadowed); + expect(entity.showInChannel, message.showInChannel); + expect(entity.replyCount, message.replyCount); + expect(entity.reactionScores, message.reactionScores); + expect(entity.reactionCounts, message.reactionCounts); + expect(entity.status, message.status); + expect(entity.updatedAt, isSameDateAs(message.updatedAt)); + expect(entity.extraData, message.extraData); + expect(entity.userId, message.user.id); + expect(entity.deletedAt, isSameDateAs(message.deletedAt)); + expect(entity.messageText, message.text); + expect(entity.pinned, message.pinned); + expect(entity.pinExpires, isSameDateAs(message.pinExpires)); + expect(entity.pinnedAt, isSameDateAs(message.pinnedAt)); + expect(entity.pinnedByUserId, message.pinnedBy.id); + expect(entity.reactionCounts, message.reactionCounts); + expect(entity.reactionScores, message.reactionScores); + expect( + entity.attachments, + message.attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + ); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart new file mode 100644 index 00000000..cb5d231e --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart @@ -0,0 +1,52 @@ +import 'package:test/test.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/mapper/reaction_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toReaction should map the entity into Reaction', () { + final user = User(id: 'testUserId'); + final message = Message(id: 'testMessageId'); + final entity = ReactionEntity( + userId: user.id, + messageId: message.id, + type: 'haha', + score: 33, + createdAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + ); + + final reaction = entity.toReaction(user: user); + expect(reaction, isA()); + expect(reaction.userId, entity.userId); + expect(reaction.messageId, entity.messageId); + expect(reaction.type, entity.type); + expect(reaction.score, entity.score); + expect(reaction.createdAt, isSameDateAs(entity.createdAt)); + expect(reaction.extraData, entity.extraData); + }); + + test('toEntity should map reaction into ReactionEntity', () { + final user = User(id: 'testUserId'); + final message = Message(id: 'testMessageId'); + final reaction = Reaction( + userId: user.id, + messageId: message.id, + type: 'haha', + score: 33, + createdAt: DateTime.now(), + extraData: {'extra_test_data': 'extraData'}, + ); + + final entity = reaction.toEntity(); + expect(entity, isA()); + expect(entity.userId, reaction.userId); + expect(entity.messageId, reaction.messageId); + expect(entity.type, reaction.type); + expect(entity.score, reaction.score); + expect(entity.createdAt, isSameDateAs(reaction.createdAt)); + expect(entity.extraData, reaction.extraData); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart new file mode 100644 index 00000000..73c87da3 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart @@ -0,0 +1,41 @@ +import 'package:test/test.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/mapper/read_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toRead should map entity into Read', () { + const cid = 'testCid'; + final user = User(id: 'testUserId'); + final entity = ReadEntity( + lastRead: DateTime.now(), + userId: user.id, + channelCid: cid, + unreadMessages: 33, + ); + + final read = entity.toRead(user: user); + expect(read, isA()); + expect(read.lastRead, isSameDateAs(entity.lastRead)); + expect(read.user.id, entity.userId); + expect(read.unreadMessages, entity.unreadMessages); + }); + + test('toEntity should map read into ReadEntity', () { + const cid = 'testCid'; + final user = User(id: 'testUserId'); + final read = Read( + lastRead: DateTime.now(), + user: user, + unreadMessages: 33, + ); + + final entity = read.toEntity(cid: cid); + expect(entity, isA()); + expect(entity.lastRead, isSameDateAs(read.lastRead)); + expect(entity.userId, read.user.id); + expect(entity.unreadMessages, read.unreadMessages); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart new file mode 100644 index 00000000..5e40f237 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart @@ -0,0 +1,55 @@ +import 'dart:math' as math; +import 'package:test/test.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/mapper/user_mapper.dart'; + +import '../utils/date_matcher.dart'; + +void main() { + test('toUser should map entity into User', () { + final entity = UserEntity( + id: 'testUserId', + role: 'testType', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: math.Random().nextBool(), + banned: math.Random().nextBool(), + extraData: {'test_extra_data': 'extraData'}, + ); + final user = entity.toUser(); + expect(user, isA()); + expect(user.id, entity.id); + expect(user.role, entity.role); + expect(user.createdAt, isSameDateAs(entity.createdAt)); + expect(user.updatedAt, isSameDateAs(entity.updatedAt)); + expect(user.lastActive, isSameDateAs(entity.lastActive)); + expect(user.online, entity.online); + expect(user.banned, entity.banned); + expect(user.extraData, entity.extraData); + }); + + test('toEntity should map user into UserEntity', () { + final user = User( + id: 'testUserId', + role: 'testType', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: math.Random().nextBool(), + banned: math.Random().nextBool(), + extraData: {'test_extra_data': 'extraData'}, + ); + final entity = user.toEntity(); + expect(entity, isA()); + expect(entity.id, user.id); + expect(entity.role, user.role); + expect(entity.createdAt, isSameDateAs(user.createdAt)); + expect(entity.updatedAt, isSameDateAs(user.updatedAt)); + expect(entity.lastActive, isSameDateAs(user.lastActive)); + expect(entity.online, user.online); + expect(entity.banned, user.banned); + expect(entity.extraData, user.extraData); + }); +} diff --git a/packages/stream_chat_persistence/test/src/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/src/stream_chat_persistence_client_test.dart deleted file mode 100644 index 561ca04d..00000000 --- a/packages/stream_chat_persistence/test/src/stream_chat_persistence_client_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; -import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart'; -import 'package:test/test.dart'; - -void main() { - group('connect', () { - test('throws exception because already connected', () { - final streamChatPersistenceClient = StreamChatPersistenceClient( - connectionMode: ConnectionMode.background, - logLevel: Level.INFO, - )..db = MoorChatDatabase( - 'test', - persistOnDisk: false, - ); - - expect( - () => streamChatPersistenceClient.connect('test'), - throwsA(allOf(isException, predicate((e) { - return e.message == - 'An instance of StreamChatDatabase is already connected.\n' - 'disconnect the previous instance before connecting again.'; - }))), - ); - }); - }); -} diff --git a/packages/stream_chat_persistence/test/src/utils/date_matcher.dart b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart new file mode 100644 index 00000000..909e58b0 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart @@ -0,0 +1,26 @@ +import 'package:test/test.dart'; +import 'package:meta/meta.dart'; + +Matcher isSameDateAs(DateTime targetDate) => + _IsSameDateAs(targetDate: targetDate); + +class _IsSameDateAs extends Matcher { + const _IsSameDateAs({ + @required this.targetDate, + }) : assert(targetDate != null, ''); + + final DateTime targetDate; + + @override + bool matches(covariant DateTime date, Map matchState) => + date.year == targetDate.year && + date.month == targetDate.month && + date.day == targetDate.day && + date.hour == targetDate.hour && + date.minute == targetDate.minute && + date.second == targetDate.second; + + @override + Description describe(Description description) => + description.add('is same date as $targetDate'); +} diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart new file mode 100644 index 00000000..55a5113a --- /dev/null +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -0,0 +1,540 @@ +import 'package:mockito/mockito.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/stream_chat_persistence_client.dart'; +import 'package:test/test.dart'; +import 'mock_chat_database.dart'; +import 'src/utils/date_matcher.dart'; + +MoorChatDatabase _testDatabaseProvider(String userId, ConnectionMode mode) => + MoorChatDatabase.testable(userId); + +void main() { + group('client constructor', () { + test('throws assertion error if null connectionMode is provided', () { + expect( + () => StreamChatPersistenceClient(connectionMode: null), + throwsA(isA()), + ); + }); + + test('throws assertion error if null logLevel is provided', () { + expect( + () => StreamChatPersistenceClient(logLevel: null), + throwsA(isA()), + ); + }); + }); + + group('connect', () { + const userId = 'testUserId'; + test('successfully connects with the Database', () async { + final client = StreamChatPersistenceClient(logLevel: Level.ALL); + expect(client.db, isNull); + await client.connect(userId, databaseProvider: _testDatabaseProvider); + expect(client.db, isNotNull); + expect(client.db, isA()); + expect(client.db.userId, userId); + + addTearDown(() async { + await client.disconnect(); + }); + }); + + test('throws if already connected', () async { + final client = StreamChatPersistenceClient(logLevel: Level.ALL); + expect(client.db, isNull); + await client.connect(userId, databaseProvider: _testDatabaseProvider); + expect(client.db, isNotNull); + expect(client.db, isNotNull); + expect(client.db, isA()); + expect(client.db.userId, userId); + expect( + () => client.connect(userId, databaseProvider: _testDatabaseProvider), + throwsException, + ); + + addTearDown(() async { + await client.disconnect(); + }); + }); + }); + + test('disconnect', () async { + const userId = 'testUserId'; + final client = StreamChatPersistenceClient(logLevel: Level.ALL); + await client.connect(userId, databaseProvider: _testDatabaseProvider); + expect(client.db, isNotNull); + await client.disconnect(flush: true); + expect(client.db, isNull); + }); + + group('client functions', () { + const userId = 'testUserId'; + final mockDatabase = MockChatDatabase(); + MoorChatDatabase _mockDatabaseProvider(_, __) => mockDatabase; + StreamChatPersistenceClient client; + + setUp(() async { + client = StreamChatPersistenceClient(logLevel: Level.ALL); + await client.connect(userId, databaseProvider: _mockDatabaseProvider); + }); + + test('getReplies', () async { + const parentId = 'testParentId'; + final replies = List.generate(3, (index) => Message(id: 'testId$index')); + + when(mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) + .thenAnswer((_) async => replies); + + final fetchedReplies = await client.getReplies(parentId); + expect(fetchedReplies.length, replies.length); + verify(mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) + .called(1); + }); + + test('getConnectionInfo', () async { + final event = Event(type: 'testEvent'); + when(mockDatabase.connectionEventDao.connectionEvent) + .thenAnswer((_) async => event); + + final fetchedEvent = await client.getConnectionInfo(); + expect(fetchedEvent.type, event.type); + verify(mockDatabase.connectionEventDao.connectionEvent).called(1); + }); + + test('getLastSyncAt', () async { + final lastSync = DateTime.now(); + when(mockDatabase.connectionEventDao.lastSyncAt) + .thenAnswer((_) async => lastSync); + + final fetchedLastSync = await client.getLastSyncAt(); + expect(fetchedLastSync, isSameDateAs(lastSync)); + verify(mockDatabase.connectionEventDao.lastSyncAt).called(1); + }); + + test('updateConnectionInfo', () async { + final event = Event(type: 'testEvent'); + when(mockDatabase.connectionEventDao.updateConnectionEvent(event)) + .thenAnswer((_) async { + return; + }); + + await client.updateConnectionInfo(event); + verify(mockDatabase.connectionEventDao.updateConnectionEvent(event)) + .called(1); + }); + + test('updateLastSyncAt', () async { + final lastSync = DateTime.now(); + when(mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) + .thenAnswer((_) { + return; + }); + + await client.updateLastSyncAt(lastSync); + verify(mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) + .called(1); + }); + + test('getChannelCids', () async { + final channelCids = List.generate(3, (index) => 'testCid$index'); + when(mockDatabase.channelDao.cids).thenAnswer((_) async => channelCids); + + final fetchedChannelCids = await client.getChannelCids(); + expect(fetchedChannelCids.length, channelCids.length); + verify(mockDatabase.channelDao.cids).called(1); + }); + + test('getChannelByCid', () async { + const cid = 'testCid'; + final channelModel = ChannelModel(cid: cid); + when(mockDatabase.channelDao.getChannelByCid(cid)) + .thenAnswer((_) async => channelModel); + + final fetchedChannelModel = await client.getChannelByCid(cid); + expect(fetchedChannelModel.cid, channelModel.cid); + verify(mockDatabase.channelDao.getChannelByCid(cid)).called(1); + }); + + test('getMembersByCid', () async { + const cid = 'testCid'; + final members = List.generate(3, (index) => Member()); + when(mockDatabase.memberDao.getMembersByCid(cid)) + .thenAnswer((_) async => members); + + final fetchedMembers = await client.getMembersByCid(cid); + expect(fetchedMembers.length, members.length); + verify(mockDatabase.memberDao.getMembersByCid(cid)).called(1); + }); + + test('getReadsByCid', () async { + const cid = 'testCid'; + final reads = List.generate(3, (index) => Read()); + when(mockDatabase.readDao.getReadsByCid(cid)) + .thenAnswer((_) async => reads); + + final fetchedReads = await client.getReadsByCid(cid); + expect(fetchedReads.length, reads.length); + verify(mockDatabase.readDao.getReadsByCid(cid)).called(1); + }); + + test('getMessagesByCid', () async { + const cid = 'testCid'; + final messages = List.generate(3, (index) => Message()); + when(mockDatabase.messageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + + final fetchedMessages = await client.getMessagesByCid(cid); + expect(fetchedMessages.length, messages.length); + verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(1); + }); + + test('getPinnedMessagesByCid', () async { + const cid = 'testCid'; + final messages = List.generate(3, (index) => Message()); + when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + + final fetchedMessages = await client.getPinnedMessagesByCid(cid); + expect(fetchedMessages.length, messages.length); + verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); + }); + + test('getChannelStateByCid', () async { + const cid = 'testCid'; + final messages = List.generate(3, (index) => Message()); + final members = List.generate(3, (index) => Member()); + final reads = List.generate(3, (index) => Read()); + final channel = ChannelModel(cid: cid); + + when(mockDatabase.memberDao.getMembersByCid(cid)) + .thenAnswer((_) async => members); + when(mockDatabase.readDao.getReadsByCid(cid)) + .thenAnswer((_) async => reads); + when(mockDatabase.channelDao.getChannelByCid(cid)) + .thenAnswer((_) async => channel); + when(mockDatabase.messageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + + final fetchedChannelState = await client.getChannelStateByCid(cid); + expect(fetchedChannelState.messages.length, messages.length); + expect(fetchedChannelState.pinnedMessages.length, messages.length); + expect(fetchedChannelState.members.length, members.length); + expect(fetchedChannelState.read.length, reads.length); + expect(fetchedChannelState.channel.cid, channel.cid); + + verify(mockDatabase.memberDao.getMembersByCid(cid)).called(1); + verify(mockDatabase.readDao.getReadsByCid(cid)).called(1); + verify(mockDatabase.channelDao.getChannelByCid(cid)).called(1); + verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(1); + verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); + }); + + test('getChannelStates', () async { + const cid = 'testCid'; + final channels = List.generate(3, (index) => ChannelModel(cid: cid)); + final messages = List.generate(3, (index) => Message()); + final members = List.generate(3, (index) => Member()); + final reads = List.generate(3, (index) => Read()); + final channel = ChannelModel(cid: cid); + final channelStates = channels + .map( + (channel) => ChannelState( + channel: channel, + messages: messages, + pinnedMessages: messages, + members: members, + read: reads, + ), + ) + .toList(growable: false); + + when(mockDatabase.channelQueryDao.getChannels()) + .thenAnswer((_) async => channels); + when(mockDatabase.memberDao.getMembersByCid(cid)) + .thenAnswer((_) async => members); + when(mockDatabase.readDao.getReadsByCid(cid)) + .thenAnswer((_) async => reads); + when(mockDatabase.channelDao.getChannelByCid(cid)) + .thenAnswer((_) async => channel); + when(mockDatabase.messageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + + final fetchedChannelStates = await client.getChannelStates(); + expect(fetchedChannelStates.length, channelStates.length); + + for (var i = 0; i < fetchedChannelStates.length; i++) { + final original = channelStates[i]; + final fetched = fetchedChannelStates[i]; + expect(fetched.members.length, original.members.length); + expect(fetched.messages.length, original.messages.length); + expect(fetched.pinnedMessages.length, original.pinnedMessages.length); + expect(fetched.read.length, original.read.length); + expect(fetched.channel.cid, original.channel.cid); + } + + verify(mockDatabase.channelQueryDao.getChannels()).called(1); + verify(mockDatabase.memberDao.getMembersByCid(cid)).called(3); + verify(mockDatabase.readDao.getReadsByCid(cid)).called(3); + verify(mockDatabase.channelDao.getChannelByCid(cid)).called(3); + verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(3); + verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(3); + }); + + test('updateChannelQueries', () async { + const filter = {}; + const cids = []; + when(mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + .thenAnswer((realInvocation) async { + return; + }); + + await client.updateChannelQueries(filter, cids); + verify(mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + .called(1); + }); + + test('deleteMessageById', () async { + const messageId = 'testMessageId'; + when(mockDatabase.messageDao.deleteMessageByIds([messageId])) + .thenAnswer((_) async { + return; + }); + + await client.deleteMessageById(messageId); + verify(mockDatabase.messageDao.deleteMessageByIds([messageId])).called(1); + }); + + test('deletePinnedMessageById', () async { + const messageId = 'testMessageId'; + when(mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) + .thenAnswer((_) async { + return; + }); + + await client.deletePinnedMessageById(messageId); + verify(mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) + .called(1); + }); + + test('deleteMessageByIds', () async { + const messageIds = []; + when(mockDatabase.messageDao.deleteMessageByIds(messageIds)) + .thenAnswer((_) async { + return; + }); + + await client.deleteMessageByIds(messageIds); + verify(mockDatabase.messageDao.deleteMessageByIds(messageIds)).called(1); + }); + + test('deletePinnedMessageByIds', () async { + const messageIds = []; + when(mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) + .thenAnswer((_) async { + return; + }); + + await client.deletePinnedMessageByIds(messageIds); + verify(mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) + .called(1); + }); + + test('deleteMessageByCid', () async { + const cid = 'testCid'; + when(mockDatabase.messageDao.deleteMessageByCids([cid])) + .thenAnswer((_) async { + return; + }); + + await client.deleteMessageByCid(cid); + verify(mockDatabase.messageDao.deleteMessageByCids([cid])).called(1); + }); + + test('deletePinnedMessageByCid', () async { + const cid = 'testCid'; + when(mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) + .thenAnswer((_) async { + return; + }); + + await client.deletePinnedMessageByCid(cid); + verify(mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) + .called(1); + }); + + test('deleteMessageByCids', () async { + const cids = []; + when(mockDatabase.messageDao.deleteMessageByCids(cids)) + .thenAnswer((_) async { + return; + }); + + await client.deleteMessageByCids(cids); + verify(mockDatabase.messageDao.deleteMessageByCids(cids)).called(1); + }); + + test('deletePinnedMessageByCids', () async { + const cids = []; + when(mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) + .thenAnswer((_) async { + return; + }); + + await client.deletePinnedMessageByCids(cids); + verify(mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)).called(1); + }); + + test('deleteChannels', () async { + const cids = []; + when(mockDatabase.channelDao.deleteChannelByCids(cids)) + .thenAnswer((_) async { + return; + }); + + await client.deleteChannels(cids); + verify(mockDatabase.channelDao.deleteChannelByCids(cids)).called(1); + }); + + test('updateMessages', () async { + const cid = 'testCid'; + final messages = List.generate(3, (index) => Message()); + when(mockDatabase.messageDao.updateMessages(cid, messages)) + .thenAnswer((_) async { + return; + }); + + await client.updateMessages(cid, messages); + verify(mockDatabase.messageDao.updateMessages(cid, messages)).called(1); + }); + + test('updatePinnedMessages', () async { + const cid = 'testCid'; + final messages = List.generate(3, (index) => Message()); + when(mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) + .thenAnswer((_) async { + return; + }); + + await client.updatePinnedMessages(cid, messages); + verify(mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) + .called(1); + }); + + test('getChannelThreads', () async { + const cid = 'testCid'; + final messages = + List.generate(3, (index) => Message(parentId: 'testParentId$index')); + final threads = messages.fold>>( + {}, + (prev, curr) { + return prev + ..update( + curr.parentId, + (value) => [...value, curr], + ifAbsent: () => [], + ); + }, + ); + when(mockDatabase.messageDao.getThreadMessages(cid)) + .thenAnswer((realInvocation) async => messages); + + final fetchedThreads = await client.getChannelThreads(cid); + expect(fetchedThreads.length, threads.length); + for (var i = 0; i < fetchedThreads.length; i++) { + final original = threads.entries.elementAt(i); + final fetched = fetchedThreads.entries.elementAt(i); + expect(fetched.key, original.key); + } + + verify(mockDatabase.messageDao.getThreadMessages(cid)).called(1); + }); + + test('updateChannels', () async { + final channels = List.generate(3, (index) => ChannelModel()); + when(mockDatabase.channelDao.updateChannels(channels)) + .thenAnswer((_) async { + return; + }); + + await client.updateChannels(channels); + verify(mockDatabase.channelDao.updateChannels(channels)).called(1); + }); + + test('updateMembers', () async { + const cid = 'testCid'; + final members = List.generate(3, (index) => Member()); + when(mockDatabase.memberDao.updateMembers(cid, members)) + .thenAnswer((_) async { + return; + }); + + await client.updateMembers(cid, members); + verify(mockDatabase.memberDao.updateMembers(cid, members)).called(1); + }); + + test('updateReads', () async { + const cid = 'testCid'; + final reads = List.generate(3, (index) => Read()); + when(mockDatabase.readDao.updateReads(cid, reads)).thenAnswer((_) async { + return; + }); + + await client.updateReads(cid, reads); + verify(mockDatabase.readDao.updateReads(cid, reads)).called(1); + }); + + test('updateUsers', () async { + final users = List.generate(3, (index) => User()); + when(mockDatabase.userDao.updateUsers(users)).thenAnswer((_) async { + return; + }); + + await client.updateUsers(users); + verify(mockDatabase.userDao.updateUsers(users)).called(1); + }); + + test('updateReactions', () async { + final reactions = List.generate(3, (index) => Reaction()); + when(mockDatabase.reactionDao.updateReactions(reactions)) + .thenAnswer((_) async { + return; + }); + + await client.updateReactions(reactions); + verify(mockDatabase.reactionDao.updateReactions(reactions)).called(1); + }); + + test('deleteReactionsByMessageId', () async { + final messageIds = []; + when(mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) + .thenAnswer((_) async { + return; + }); + + await client.deleteReactionsByMessageId(messageIds); + verify(mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) + .called(1); + }); + + test('deleteMembersByCids', () async { + final cids = []; + when(mockDatabase.memberDao.deleteMemberByCids(cids)) + .thenAnswer((_) async { + return; + }); + + await client.deleteMembersByCids(cids); + verify(mockDatabase.memberDao.deleteMemberByCids(cids)).called(1); + }); + + tearDown(() async { + await client.disconnect(flush: true); + }); + }); +}