diff --git a/.github/workflows/dispatch_workflows.yml b/.github/workflows/dispatch_workflows.yml new file mode 100644 index 000000000..5743a0aa9 --- /dev/null +++ b/.github/workflows/dispatch_workflows.yml @@ -0,0 +1,30 @@ +name: dispatch_workflows + +env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + +on: + push: + branches: + - develop + - master + +jobs: + dispatch_nightly: + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - uses: benc-uk/workflow-dispatch@v1 + with: + workflow: build_nightly + repo: GetStream/flutter-samples + token: ${{ secrets.GH_TOKEN }} + dispatch_stable: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: benc-uk/workflow-dispatch@v1 + with: + repo: GetStream/flutter-samples + workflow: build + token: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/pana.yml b/.github/workflows/pana.yml index e698349c8..4341e4801 100644 --- a/.github/workflows/pana.yml +++ b/.github/workflows/pana.yml @@ -32,7 +32,7 @@ jobs: TOTAL_MAX: ${{ steps.analysis.outputs.total_max }} run: | PERCENTAGE=$(( $TOTAL * 100 / $TOTAL_MAX )) - if (( $PERCENTAGE < 100 )) + if (( $PERCENTAGE < 90 )) then echo Score too low! exit 1 diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml new file mode 100644 index 000000000..6f4ad786d --- /dev/null +++ b/.github/workflows/pr_title.yml @@ -0,0 +1,22 @@ +name: 'PR Title is Conventional' +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v2.1.0 + with: + scopes: | + llc + persistence + core + ui + requireScope: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index ce6d2c639..25dab1f47 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -30,7 +30,7 @@ jobs: run: melos bootstrap - name: 'Dart Analyze' run: | - melos exec -c 3 -- \ + melos exec -c 3 --ignore="*example*" -- \ tuneup check - name: 'Pub Check' run: | diff --git a/.gitignore b/.gitignore index f7a23bbb4..c75573b20 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ build/ .project .classpath .settings -/.fvm +**/.fvm .melos_tool/ /packages/flutter_widgets/example/ios/Flutter/.last_build_id diff --git a/README.md b/README.md index 6260cf244..7f4a523ef 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ This repository contains code for our [Dart](https://dart.dev/) and [Flutter](ht Stream allows developers to rapidly deploy scalable feeds and chat messaging with an industry leading 99.999% uptime SLA guarantee. +## Sample apps and demos +Our team maintains a dedicated repository for fully-fledged sample applications and demos. Consider checking out [GetStream/flutter-samples](https://github.com/GetStream/flutter-samples) to learn more or get started by looking at our latest [Stream Chat demo](https://github.com/GetStream/flutter-samples/tree/main/stream_chat_v1). + ## Structure Stream Chat Dart is a monorepo built using [Melos](https://docs.page/invertase/melos). Individual packages can be found in the `packages` directory while configuration and top level commands can be found in `melos.yaml`. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 02825f581..4e30d4527 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.0-beta + +- Improved attachment uploading +- Fix: update member presence +- Added skip_push to message model +- Minor fixes and improvements + ## 1.3.2+1-beta - Fixed queryChannels bug diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml index 24e35a1f0..7e4b00faf 100644 --- a/packages/stream_chat/analysis_options.yaml +++ b/packages/stream_chat/analysis_options.yaml @@ -1,63 +1,147 @@ -include: package:pedantic/analysis_options.yaml - analyzer: - exclude: + exclude: - lib/**/*.g.dart - lib/**/*.freezed.dart - example/* - test/* - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - # - always_declare_return_types - # - always_specify_types - # - annotate_overrides - # - avoid_as +linter: + rules: + - always_use_package_imports - avoid_empty_else - - avoid_init_to_null - - avoid_return_types_on_setters - - avoid_web_libraries_in_flutter - - await_only_futures - - camel_case_types + - avoid_relative_lib_imports + - avoid_slow_async_io + - avoid_types_as_parameter_names - cancel_subscriptions - close_sinks - # - comment_references # we do not presume as to what people want to reference in their dartdocs - # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 - control_flow_in_finally - - empty_constructor_bodies + - diagnostic_describe_all_properties - 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_put_required_named_parameters_first + - 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 - # - invariant_booleans - # - iterable_contains_unrelated_type + - join_return_with_assignment + - leading_newlines_in_multiline_strings - library_names - # - library_prefixes - # - list_remove_unrelated_type - # - literal_only_boolean_expressions + - library_prefixes + - lines_longer_than_80_chars + - missing_whitespace_between_adjacent_strings - non_constant_identifier_names - # - one_member_abstracts - # - only_throw_errors - # - overridden_fields + - null_closures + - one_member_abstracts + - only_throw_errors - package_api_docs - - package_names - 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_mixin # https://github.com/dart-lang/language/issues/32 + - 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_constructors_first - # - sort_unnamed_constructors_first - # - super_goes_last # no longer needed w/ Dart 2 - - test_types_in_equals - - throw_in_finally - # - type_annotate_public_apis # subset of always_specify_types + - sort_child_properties_last + - sort_constructors_first + - sort_unnamed_constructors_first + + - type_annotate_public_apis - type_init_formals - # - unawaited_futures + - unnecessary_await_in_return - unnecessary_brace_in_string_interps + - unnecessary_const - unnecessary_getters_setters - - unnecessary_statements - - unrelated_type_equality_checks - - valid_regexps \ No newline at end of file + - 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/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 8814bfb09..76209bfd0 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -2,24 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; -import 'package:pedantic/pedantic.dart' show unawaited; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/api/retry_queue.dart'; -import 'package:stream_chat/src/debounce.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/models/attachment_file.dart'; import 'package:stream_chat/src/models/channel_state.dart'; import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/stream_chat.dart'; - -import '../client.dart'; -import '../models/event.dart'; -import '../models/member.dart'; -import '../models/message.dart'; -import 'requests.dart'; -import 'responses.dart'; +import 'package:stream_chat/src/extensions/rate_limit.dart'; /// This a the class that manages a specific channel. class Channel { @@ -57,7 +49,8 @@ class Channel { set extraData(Map extraData) { if (_initializedCompleter.isCompleted) { throw Exception( - 'Once the channel is initialized you should use channel.update to update channel data'); + 'Once the channel is initialized you should use channel.update ' + 'to update channel data'); } _extraData = extraData; } @@ -168,14 +161,15 @@ class Channel { final Completer _initializedCompleter = Completer(); /// True if this is initialized - /// Call [watch] to initialize the client or instantiate it using [Channel.fromState] + /// Call [watch] to initialize the client or instantiate it using + /// [Channel.fromState] Future get initialized => _initializedCompleter.future; final _cancelableAttachmentUploadRequest = {}; final _messageAttachmentsUploadCompleter = {}; - /// Cancels [attachmentId] upload request. Throws exception if the request hasn't - /// even started yet, Already completed or Already cancelled. + /// Cancels [attachmentId] upload request. Throws exception if the request + /// hasn't even started yet, Already completed or Already cancelled. /// /// Optionally, provide a [reason] for the cancellation. void cancelAttachmentUpload( @@ -185,7 +179,8 @@ class Channel { final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; if (cancelToken == null) { throw Exception( - "Upload request for this Attachment hasn't started yet or else Already completed", + "Upload request for this Attachment hasn't started yet or else " + 'Already completed', ); } if (cancelToken.isCancelled) throw Exception('Already cancelled'); @@ -193,15 +188,14 @@ class Channel { } /// Retries the failed [attachmentId] upload request. - Future retryAttachmentUpload(String messageId, String attachmentId) { - return _uploadAttachments(messageId, [attachmentId]); - } + Future retryAttachmentUpload(String messageId, String attachmentId) => + _uploadAttachments(messageId, [attachmentId]); Future _uploadAttachments( String messageId, Iterable attachmentIds, ) { - var message = state.messages.firstWhere( + final message = state.messages.firstWhere( (it) => it.id == messageId, orElse: () => null, ); @@ -224,29 +218,29 @@ class Channel { } client.logger.info('Found ${attachments.length} attachments'); + + void updateAttachment(Attachment attachment) { + final index = + message.attachments.indexWhere((it) => it.id == attachment.id); + if (index != -1) { + message.attachments[index] = attachment; + state?.addMessage(message); + } + } + return Future.wait(attachments.map((it) { client.logger.info('Uploading ${it.id} attachment...'); - void updateAttachment(Attachment attachment) { - final index = message.attachments.indexWhere((it) { - return it.id == attachment.id; - }); - if (index != -1) { - message.attachments[index] = attachment; - state?.addMessage(message); - } - } + final throttledUpdateAttachment = updateAttachment.throttled( + const Duration(milliseconds: 500), + ); void onSendProgress(int sent, int total) { - debounce( - timeout: Duration(seconds: 1), - target: updateAttachment, - positionalArguments: [ - it.copyWith( - uploadState: UploadState.inProgress(uploaded: sent, total: total), - ), - ], - ); + throttledUpdateAttachment([ + it.copyWith( + uploadState: UploadState.inProgress(uploaded: sent, total: total), + ), + ]); } final isImage = it.type == 'image'; @@ -270,18 +264,26 @@ class Channel { client.logger.info('Attachment ${it.id} uploaded successfully...'); if (isImage) { updateAttachment( - it.copyWith(imageUrl: url, uploadState: UploadState.success()), + it.copyWith( + imageUrl: url, + uploadState: const UploadState.success(), + ), ); } else { updateAttachment( - it.copyWith(assetUrl: url, uploadState: UploadState.success()), + it.copyWith( + assetUrl: url, + uploadState: const UploadState.success(), + ), ); } }).catchError((e, stk) { + client.logger.severe('error uploading the attachment', e, stk); updateAttachment( it.copyWith(uploadState: UploadState.failed(error: e.toString())), ); }).whenComplete(() { + throttledUpdateAttachment?.cancel(); _cancelableAttachmentUploadRequest.remove(it.id); }); })).whenComplete(() { @@ -305,6 +307,7 @@ class Channel { (m) => m.id == message?.quotedMessageId, orElse: () => null, ); + // ignore: parameter_assignments message = message.copyWith( createdAt: message.createdAt ?? DateTime.now(), user: _client.state.user, @@ -313,7 +316,7 @@ class Channel { attachments: message.attachments?.map( (it) { if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: UploadState.preparing()); + return it.copyWith(uploadState: const UploadState.preparing()); }, )?.toList(), ); @@ -335,11 +338,13 @@ class Channel { _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - unawaited(_uploadAttachments( + // ignore: unawaited_futures + _uploadAttachments( message.id, message.attachments.map((it) => it.id), - )); + ); + // ignore: parameter_assignments message = await attachmentsUploadCompleter.future; } @@ -364,13 +369,14 @@ class Channel { .remove(message.id) ?.completeError('Message Cancelled'); + // ignore: parameter_assignments message = message.copyWith( status: MessageSendingStatus.updating, updatedAt: message.updatedAt ?? DateTime.now(), attachments: message.attachments?.map( (it) { if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: UploadState.preparing()); + return it.copyWith(uploadState: const UploadState.preparing()); }, )?.toList(), ); @@ -383,11 +389,13 @@ class Channel { _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - unawaited(_uploadAttachments( + // ignore: unawaited_futures + _uploadAttachments( message.id, message.attachments.map((it) => it.id), - )); + ); + // ignore: parameter_assignments message = await attachmentsUploadCompleter.future; } @@ -423,6 +431,7 @@ class Channel { } try { + // ignore: parameter_assignments message = message.copyWith( type: 'deleted', status: MessageSendingStatus.deleting, @@ -456,7 +465,7 @@ class Channel { throw ArgumentError('Invalid timeout or Expiration date'); } return true; - }()); + }(), 'Check for invalid token or expiration date'); DateTime pinExpires; if (timeoutOrExpirationDate is DateTime) { @@ -475,39 +484,36 @@ class Channel { } /// Unpins provided message - Future unpinMessage(Message message) { - return updateMessage(message.copyWith(pinned: false)); - } + Future unpinMessage(Message message) => + updateMessage(message.copyWith(pinned: false)); /// Send a file to this channel Future sendFile( AttachmentFile file, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) { - return _client.sendFile( - file, - id, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - } + }) => + _client.sendFile( + file, + id, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); /// Send an image to this channel Future sendImage( AttachmentFile file, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) { - return _client.sendImage( - file, - id, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - } + }) => + _client.sendImage( + file, + id, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); /// A message search. Future search({ @@ -515,35 +521,32 @@ class Channel { Map messageFilters, List sort, PaginationParams paginationParams, - }) { - return _client.search( - { - 'cid': { - r'$in': [cid], + }) => + _client.search( + { + 'cid': { + r'$in': [cid], + }, }, - }, - sort: sort, - query: query, - paginationParams: paginationParams, - messageFilters: messageFilters, - ); - } + sort: sort, + query: query, + paginationParams: paginationParams, + messageFilters: messageFilters, + ); /// Delete a file from this channel Future deleteFile( String url, { CancelToken cancelToken, - }) { - return _client.deleteFile(url, id, type, cancelToken: cancelToken); - } + }) => + _client.deleteFile(url, id, type, cancelToken: cancelToken); /// Delete an image from this channel Future deleteImage( String url, { CancelToken cancelToken, - }) { - return _client.deleteImage(url, id, type, cancelToken: cancelToken); - } + }) => + _client.deleteImage(url, id, type, cancelToken: cancelToken); /// Send an event on this channel Future sendEvent(Event event) { @@ -551,9 +554,7 @@ class Channel { return _client.post( '$_channelURL/event', data: {'event': event.toJson()}, - ).then((res) { - return _client.decode(res.data, EmptyResponse.fromJson); - }); + ).then((res) => _client.decode(res.data, EmptyResponse.fromJson)); } /// Send a reaction to this channel @@ -643,11 +644,10 @@ class Channel { } final latestReactions = [...message.latestReactions ?? []] - ..removeWhere((r) { - return r.userId == reaction.userId && - r.type == reaction.type && - r.messageId == reaction.messageId; - }); + ..removeWhere((r) => + r.userId == reaction.userId && + r.type == reaction.type && + r.messageId == reaction.messageId); final ownReactions = [...latestReactions ?? []] ..removeWhere((it) => it.userId != user.id); @@ -825,7 +825,7 @@ class Channel { }) ..addAll(options); - var response; + ChannelState response; try { response = await query(options: watchOptions); @@ -861,7 +861,8 @@ class Channel { } /// List the message replies for a parent message - /// Set [preferOffline] to true to avoid the api call if the data is already in the offline storage + /// Set [preferOffline] to true to avoid the api call if the data is already + /// in the offline storage Future getReplies( String parentId, PaginationParams options, { @@ -940,16 +941,15 @@ class Channel { } /// Creates a new channel - Future create() async { - return query(options: { - 'watch': false, - 'state': false, - 'presence': false, - }); - } + Future create() async => query(options: { + 'watch': false, + 'state': false, + 'presence': false, + }); /// Query the API, get messages, members or other channel fields - /// Set [preferOffline] to true to avoid the api call if the data is already in the offline storage + /// Set [preferOffline] to true to avoid the api call if the data is already + /// in the offline storage Future query({ Map options = const {}, PaginationParams messagesPagination, @@ -1112,8 +1112,9 @@ class Channel { }); } - /// Hides the channel from [StreamChatClient.queryChannels] for the user until a message is added - /// If [clearHistory] is set to true - all messages will be removed for the user + /// Hides the channel from [StreamChatClient.queryChannels] for the user + /// until a message is added If [clearHistory] is set to true - all messages + /// will be removed for the user Future hide({bool clearHistory = false}) async { _checkInitialized(); final response = await _client @@ -1134,28 +1135,28 @@ class Channel { return _client.decode(response.data, EmptyResponse.fromJson); } - /// Stream of [Event] coming from websocket connection specific for the channel - /// Pass an eventType as parameter in order to filter just a type of event + /// Stream of [Event] coming from websocket connection specific for the + /// channel. Pass an eventType as parameter in order to filter just a type + /// of event Stream on([ String eventType, String eventType2, String eventType3, String eventType4, - ]) { - return _client - .on( - eventType, - eventType2, - eventType3, - eventType4, - ) - .where((e) => e.cid == cid); - } + ]) => + _client + .on( + eventType, + eventType2, + eventType3, + eventType4, + ) + .where((e) => e.cid == cid); DateTime _lastTypingEvent; - /// First of the [EventType.typingStart] and [EventType.typingStop] events based on the users keystrokes. - /// Call this on every keystroke. + /// First of the [EventType.typingStart] and [EventType.typingStop] events + /// based on the users keystrokes. Call this on every keystroke. Future keyStroke([String parentId]) async { if (config?.typingEvents == false) { return; @@ -1196,17 +1197,21 @@ class Channel { void _checkInitialized() { if (!_initializedCompleter.isCompleted) { throw Exception( - "Channel $cid hasn't been initialized yet. Make sure to call .watch() or to instantiate the client using [Channel.fromState]"); + "Channel $cid hasn't been initialized yet. Make sure to call .watch()" + ' or to instantiate the client using [Channel.fromState]'); } } } /// The class that handles the state of the channel listening to the events class ChannelClientState { - final _subscriptions = []; - /// Creates a new instance listening to events and updating the state - ChannelClientState(this._channel, ChannelState channelState) { + ChannelClientState( + this._channel, + ChannelState channelState, + ) : _debouncedUpdatePersistenceChannelState = _channel + ?._client?.chatPersistenceClient?.updateChannelState + ?.debounced(const Duration(seconds: 1)) { retryQueue = RetryQueue( channel: _channel, logger: Logger('RETRY QUEUE ${_channel.cid}'), @@ -1252,14 +1257,16 @@ class ChannelClientState { _channel._client.chatPersistenceClient ?.getChannelStateByCid(_channel.cid) ?.then((state) { - // Replacing the persistence state members with the latest `channelState.members` - // as they may have changes over the time. + // Replacing the persistence state members with the latest + // `channelState.members` as they may have changes over the time. updateChannelState(state.copyWith(members: channelState.members)); retryFailedMessages(); }); }); } + final _subscriptions = []; + void _computeInitialUnread() { final userRead = channelState?.read?.firstWhere( (r) => r.user.id == _channel._client.state?.user?.id, @@ -1277,11 +1284,16 @@ class ChannelClientState { m.attachments?.isNotEmpty == true && m.attachments?.any((e) { final url = e.imageUrl ?? e.assetUrl; - if (url == null || !url.contains('stream-io-cdn.com')) { + if (url == null || !url.contains('')) { + return false; + } + final uri = Uri.parse(url); + if (uri.host != 'stream-io-cdn.com' || + uri.queryParameters['Expires'] == null) { return false; } final expiration = - DateTime.parse(Uri.parse(url).queryParameters['Expires']); + DateTime.parse(uri.queryParameters['Expires']); return expiration.isBefore(DateTime.now()); }) == true) @@ -1338,8 +1350,8 @@ class ChannelClientState { /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. /// This flag should be managed by UI sdks. - /// When false, any new message (received by WebSocket event - [EventType.messageNew]) will not - /// be pushed on to message list. + /// When false, any new message (received by WebSocket event + /// - [EventType.messageNew]) will not be pushed on to message list. bool get isUpToDate => _isUpToDateController.value; set isUpToDate(bool isUpToDate) => _isUpToDateController.add(isUpToDate); @@ -1357,12 +1369,18 @@ class ChannelClientState { Future retryFailedMessages() async { final failedMessages = [...messages, ...threads.values.expand((v) => v)] - .where((message) => - message.status != null && - message.status != MessageSendingStatus.sent && - message.createdAt.isBefore(DateTime.now().subtract(Duration( - seconds: 1, - )))) + .where( + (message) => + message.status != null && + message.status != MessageSendingStatus.sent && + message.createdAt.isBefore( + DateTime.now().subtract( + const Duration( + seconds: 1, + ), + ), + ), + ) .toList(); retryQueue.add(failedMessages); @@ -1471,27 +1489,32 @@ class ChannelClientState { return; } - _subscriptions.add(_channel - .on( - EventType.messageRead, - EventType.notificationMarkRead, - ) - .listen((event) { - final readList = List.from(_channelState?.read ?? []); - final userReadIndex = read?.indexWhere((r) => r.user.id == event.user.id); - - if (userReadIndex != null && userReadIndex != -1) { - final userRead = readList.removeAt(userReadIndex); - if (userRead.user?.id == _channel._client.state.user.id) { - _unreadCountController.add(0); - } - readList.add(Read( - user: event.user, - lastRead: event.createdAt, - )); - _channelState = _channelState.copyWith(read: readList); - } - })); + _subscriptions.add( + _channel + .on( + EventType.messageRead, + EventType.notificationMarkRead, + ) + .listen( + (event) { + final readList = List.from(_channelState?.read ?? []); + final userReadIndex = + read?.indexWhere((r) => r.user.id == event.user.id); + + if (userReadIndex != null && userReadIndex != -1) { + final userRead = readList.removeAt(userReadIndex); + if (userRead.user?.id == _channel._client.state.user.id) { + _unreadCountController.add(0); + } + readList.add(Read( + user: event.user, + lastRead: event.createdAt, + )); + _channelState = _channelState.copyWith(read: readList); + } + }, + ), + ); } /// Channel message list @@ -1527,11 +1550,8 @@ class ChannelClientState { List, Map, List>( channelStateStream.map((cs) => cs.members), _channel.client.state.usersStream, - (members, users) { - return members - .map((e) => e.copyWith(user: users[e.user.id])) - .toList(); - }, + (members, users) => + members.map((e) => e.copyWith(user: users[e.user.id])).toList(), ); /// Channel watcher count @@ -1551,9 +1571,7 @@ class ChannelClientState { CombineLatestStream.combine2, Map, List>( channelStateStream.map((cs) => cs.watchers), _channel.client.state.usersStream, - (watchers, users) { - return watchers.map((e) => users[e.id] ?? e).toList(); - }, + (watchers, users) => watchers.map((e) => users[e.id] ?? e).toList(), ); /// Channel read list @@ -1625,9 +1643,7 @@ class ChannelClientState { true) ?.toList() ?? [], - ]; - - newMessages.sort(_sortByCreatedAt); + ]..sort(_sortByCreatedAt); final newWatchers = [ ...updatedState?.watchers ?? [], @@ -1690,16 +1706,11 @@ class ChannelClientState { ChannelState get channelState => _channelStateController.value; BehaviorSubject _channelStateController; + final Debounce _debouncedUpdatePersistenceChannelState; + set _channelState(ChannelState v) { _channelStateController.add(v); - - if (_channel._client.persistenceEnabled) { - debounce( - timeout: Duration(milliseconds: 500), - target: _channel._client.chatPersistenceClient?.updateChannelState, - positionalArguments: [v], - ); - } + _debouncedUpdatePersistenceChannelState?.call([v]); } /// The channel threads related to this channel @@ -1735,28 +1746,62 @@ class ChannelClientState { return; } - _subscriptions.add(_channel.on(EventType.typingStart).listen((event) { - if (event.user.id != _channel.client.state.user.id) { - _typings[event.user] = DateTime.now(); - _typingEventsController.add(_typings.keys.toList()); - } - })); - - _subscriptions.add(_channel.on(EventType.typingStop).listen((event) { - if (event.user.id != _channel.client.state.user.id) { - _typings.remove(event.user); - _typingEventsController.add(_typings.keys.toList()); - } - })); + _subscriptions + ..add( + _channel.on(EventType.typingStart).listen( + (event) { + if (event.user.id != _channel.client.state.user.id) { + _typings[event.user] = DateTime.now(); + _typingEventsController.add(_typings.keys.toList()); + } + }, + ), + ) + ..add( + _channel.on(EventType.typingStop).listen( + (event) { + if (event.user.id != _channel.client.state.user.id) { + _typings.remove(event.user); + _typingEventsController.add(_typings.keys.toList()); + } + }, + ), + ) + ..add( + _channel + .on() + .where((event) => + event.user != null && + members?.any((m) => m.userId == event.user.id) == true) + .listen( + (event) { + final newMembers = List.from(members); + final oldMemberIndex = + newMembers.indexWhere((m) => m.userId == event.user.id); + if (oldMemberIndex > -1) { + final oldMember = newMembers.removeAt(oldMemberIndex); + updateChannelState(ChannelState( + members: [ + ...newMembers, + oldMember.copyWith( + user: event.user, + ), + ], + )); + } + }, + ), + ); } Timer _cleaningTimer; + void _startCleaning() { if (_channel.config?.typingEvents == false) { return; } - _cleaningTimer = Timer.periodic(Duration(seconds: 1), (_) { + _cleaningTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); if (_channel._lastTypingEvent != null && @@ -1769,8 +1814,9 @@ class ChannelClientState { } Timer _pinnedMessagesTimer; + void _startCleaningPinnedMessages() { - _pinnedMessagesTimer = Timer.periodic(Duration(seconds: 30), (_) { + _pinnedMessagesTimer = Timer.periodic(const Duration(seconds: 30), (_) { final now = DateTime.now(); var expiredMessages = channelState.pinnedMessages ?.where((m) => m.pinExpires?.isBefore(now) == true) @@ -1781,8 +1827,6 @@ class ChannelClientState { .map((m) => m.copyWith( pinExpires: null, pinned: false, - pinnedAt: null, - pinnedBy: null, )) .toList(); @@ -1811,6 +1855,7 @@ class ChannelClientState { /// Call this method to dispose this object void dispose() { + _debouncedUpdatePersistenceChannelState?.cancel(); _unreadCountController.close(); retryQueue.dispose(); _subscriptions.forEach((s) => s.cancel()); diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/api/requests.dart index 414113de8..764d249a8 100644 --- a/packages/stream_chat/lib/src/api/requests.dart +++ b/packages/stream_chat/lib/src/api/requests.dart @@ -5,10 +5,25 @@ part 'requests.g.dart'; /// Sorting options @JsonSerializable(createFactory: false) class SortOption { + /// Creates a new SortOption instance + /// + /// For example: + /// ```dart + /// // Sort channels by the last message date: + /// final sorting = SortOption("last_message_at") + /// ``` + const SortOption( + this.field, { + this.direction = DESC, + this.comparator, + }); + /// Ascending order + // ignore: constant_identifier_names static const ASC = 1; /// Descending order + // ignore: constant_identifier_names static const DESC = -1; /// A sorting field name @@ -21,19 +36,6 @@ class SortOption { @JsonKey(ignore: true) final Comparator comparator; - /// Creates a new SortOption instance - /// - /// For example: - /// ```dart - /// // Sort channels by the last message date: - /// final sorting = SortOption("last_message_at") - /// ``` - const SortOption( - this.field, { - this.direction = DESC, - this.comparator, - }); - /// Serialize model to json Map toJson() => _$SortOptionToJson(this); } @@ -41,6 +43,25 @@ class SortOption { /// Pagination options. @JsonSerializable(createFactory: false, includeIfNull: false) class PaginationParams { + /// Creates a new PaginationParams instance + /// + /// For example: + /// ```dart + /// // limit to 50 + /// final paginationParams = PaginationParams(limit: 50); + /// + /// // limit to 50 with offset + /// final paginationParams = PaginationParams(limit: 50, offset: 50); + /// ``` + const PaginationParams({ + this.limit = 10, + this.offset = 0, + this.greaterThan, + this.greaterThanOrEqual, + this.lessThan, + this.lessThanOrEqual, + }); + /// The amount of items requested from the APIs. final int limit; @@ -63,25 +84,6 @@ class PaginationParams { @JsonKey(name: 'id_lte') final String lessThanOrEqual; - /// Creates a new PaginationParams instance - /// - /// For example: - /// ```dart - /// // limit to 50 - /// final paginationParams = PaginationParams(limit: 50); - /// - /// // limit to 50 with offset - /// final paginationParams = PaginationParams(limit: 50, offset: 50); - /// ``` - const PaginationParams({ - this.limit = 10, - this.offset = 0, - this.greaterThan, - this.greaterThanOrEqual, - this.lessThan, - this.lessThanOrEqual, - }); - /// Serialize model to json Map toJson() => _$PaginationParamsToJson(this); diff --git a/packages/stream_chat/lib/src/api/requests.g.dart b/packages/stream_chat/lib/src/api/requests.g.dart index ef7907761..f020c1ea4 100644 --- a/packages/stream_chat/lib/src/api/requests.g.dart +++ b/packages/stream_chat/lib/src/api/requests.g.dart @@ -6,7 +6,7 @@ part of 'requests.dart'; // JsonSerializableGenerator // ************************************************************************** -Map _$SortOptionToJson(SortOption instance) => +Map _$SortOptionToJson(SortOption instance) => { 'field': instance.field, 'direction': instance.direction, diff --git a/packages/stream_chat/lib/src/api/responses.dart b/packages/stream_chat/lib/src/api/responses.dart index a1e928d65..431a57e79 100644 --- a/packages/stream_chat/lib/src/api/responses.dart +++ b/packages/stream_chat/lib/src/api/responses.dart @@ -1,15 +1,14 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/models/channel_state.dart'; import 'package:stream_chat/src/models/device.dart'; import 'package:stream_chat/src/models/event.dart'; - -import '../models/channel_model.dart'; -import '../models/channel_state.dart'; -import '../models/member.dart'; -import '../models/message.dart'; -import '../models/reaction.dart'; -import '../models/read.dart'; -import '../models/user.dart'; +import 'package:stream_chat/src/models/member.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'responses.g.dart'; diff --git a/packages/stream_chat/lib/src/api/retry_policy.dart b/packages/stream_chat/lib/src/api/retry_policy.dart index 2c59ec48b..a4d1e7bd8 100644 --- a/packages/stream_chat/lib/src/api/retry_policy.dart +++ b/packages/stream_chat/lib/src/api/retry_policy.dart @@ -18,7 +18,8 @@ class RetryPolicy { final bool Function(StreamChatClient client, int attempt, ApiError apiError) shouldRetry; - /// In the case that we want to retry a failed request the retryTimeout method is called to determine the timeout + /// In the case that we want to retry a failed request the retryTimeout + /// method is called to determine the timeout final Duration Function( StreamChatClient client, int attempt, ApiError apiError) retryTimeout; diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart index 340465457..ac0e14d77 100644 --- a/packages/stream_chat/lib/src/api/retry_queue.dart +++ b/packages/stream_chat/lib/src/api/retry_queue.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:stream_chat/src/api/channel.dart'; import 'package:stream_chat/src/api/retry_policy.dart'; import 'package:stream_chat/src/event_type.dart'; @@ -12,12 +12,6 @@ import 'package:stream_chat/stream_chat.dart'; /// The retry queue associated to a channel class RetryQueue { - /// The channel of this queue - final Channel channel; - - /// The logger associated to this queue - final Logger logger; - /// Instantiate a new RetryQueue object RetryQueue({ @required this.channel, @@ -30,6 +24,12 @@ class RetryQueue { _listenFailedEvents(); } + /// The channel of this queue + final Channel channel; + + /// The logger associated to this queue + final Logger logger; + final _subscriptions = []; void _listenConnectionRecovered() { diff --git a/packages/stream_chat/lib/src/api/websocket.dart b/packages/stream_chat/lib/src/api/websocket.dart index 15102c6e4..ea2bb291b 100644 --- a/packages/stream_chat/lib/src/api/websocket.dart +++ b/packages/stream_chat/lib/src/api/websocket.dart @@ -2,28 +2,29 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; -import 'package:meta/meta.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -import '../models/event.dart'; -import '../models/user.dart'; -import 'connection_status.dart'; -import 'web_socket_channel_stub.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'; /// Typedef which exposes an [Event] as the only parameter. typedef EventHandler = void Function(Event); -/// Typedef used for connecting to a websocket. Method returns a [WebSocketChannel] -/// and accepts a connection [url] and an optional [Iterable] of `protocols`. +/// Typedef used for connecting to a websocket. Method returns a +/// [WebSocketChannel] and accepts a connection [url] and an optional +/// [Iterable] of `protocols`. typedef ConnectWebSocket = WebSocketChannel Function(String url, {Iterable protocols}); // TODO: parse error even -// TODO: if parsing an error into an event fails we should not hide the original error +// TODO: if parsing an error into an event fails we should not hide the +// TODO: original error /// A WebSocket connection that reconnects upon failure. class WebSocket { /// Creates a new websocket @@ -75,7 +76,8 @@ class WebSocket { /// WS connection payload final Map connectPayload; - /// Functions that will be called every time a new event is received from the connection + /// Functions that will be called every time a new event is received from the + /// connection final EventHandler handler; /// A WS specific logger instance @@ -87,8 +89,9 @@ class WebSocket { final ConnectWebSocket connectFunc; /// Interval of the reconnection monitor timer - /// This checks that it received a new event in the last [reconnectionMonitorTimeout] seconds, - /// otherwise it considers the connection unhealthy and reconnects the WS + /// This checks that it received a new event in the last + /// [reconnectionMonitorTimeout] seconds, otherwise it considers the + /// connection unhealthy and reconnects the WS final int reconnectionMonitorInterval; /// Interval of the health event sending timer @@ -96,7 +99,8 @@ class WebSocket { /// make the server aware that the client is still listening final int healthCheckInterval; - /// The timeout that uses the reconnection monitor timer to consider the connection unhealthy + /// The timeout that uses the reconnection monitor timer to consider the + /// connection unhealthy final int reconnectionMonitorTimeout; final _connectionStatusController = @@ -121,9 +125,7 @@ class WebSocket { _connecting = false, _reconnecting = false; - Event _decodeEvent(String source) { - return Event.fromJson(json.decode(source)); - } + Event _decodeEvent(String source) => Event.fromJson(json.decode(source)); Completer _connectionCompleter = Completer(); @@ -166,8 +168,8 @@ class WebSocket { return; } - logger.info( - 'connection closed | closeCode: ${_channel.closeCode} | closedReason: ${_channel.closeReason}'); + logger.info('connection closed | closeCode: ${_channel.closeCode} | ' + 'closedReason: ${_channel.closeReason}'); if (!_reconnecting) { _reconnect(); @@ -200,8 +202,7 @@ class WebSocket { } Future _onConnectionError(error, [stacktrace]) async { - logger.severe('error connecting'); - logger.severe(error); + logger..severe('error connecting')..severe(error); if (stacktrace != null) { logger.severe(stacktrace); } @@ -219,21 +220,21 @@ class WebSocket { } } - void _startReconnectionMonitor() { - final reconnectionTimer = (_) { - final now = DateTime.now(); - if (_lastEventAt != null && - now.difference(_lastEventAt).inSeconds > reconnectionMonitorTimeout) { - _channel.sink.close(); - } - }; + void _reconnectionTimer(_) { + final now = DateTime.now(); + if (_lastEventAt != null && + now.difference(_lastEventAt).inSeconds > reconnectionMonitorTimeout) { + _channel.sink.close(); + } + } + void _startReconnectionMonitor() { _reconnectionMonitor = Timer.periodic( Duration(seconds: reconnectionMonitorInterval), - reconnectionTimer, + _reconnectionTimer, ); - reconnectionTimer(_reconnectionMonitor); + _reconnectionTimer(_reconnectionMonitor); } void _reconnectTimer() async { @@ -283,20 +284,20 @@ class WebSocket { } } + void _healthCheckTimer(_) { + logger.info('sending health.check'); + _channel.sink.add("{'type': 'health.check'}"); + } + void _startHealthCheck() { logger.info('start health check monitor'); - final healthCheckTimer = (_) { - logger.info('sending health.check'); - _channel.sink.add("{'type': 'health.check'}"); - }; - _healthCheck = Timer.periodic( Duration(seconds: healthCheckInterval), - healthCheckTimer, + _healthCheckTimer, ); - healthCheckTimer(_healthCheck); + _healthCheckTimer(_healthCheck); } /// Disconnects the WS and releases eventual resources diff --git a/packages/stream_chat/lib/src/attachment_file_uploader.dart b/packages/stream_chat/lib/src/attachment_file_uploader.dart index c420728f0..7d9e8c402 100644 --- a/packages/stream_chat/lib/src/attachment_file_uploader.dart +++ b/packages/stream_chat/lib/src/attachment_file_uploader.dart @@ -1,8 +1,8 @@ import 'package:dio/dio.dart'; import 'package:stream_chat/src/api/responses.dart'; +import 'package:stream_chat/src/client.dart'; import 'package:stream_chat/src/models/attachment_file.dart'; -import 'client.dart'; -import 'extensions/string_extension.dart'; +import 'package:stream_chat/src/extensions/string_extension.dart'; /// Class responsible for uploading images and files to a given channel abstract class AttachmentFileUploader { @@ -57,11 +57,11 @@ abstract class AttachmentFileUploader { /// Stream's default implementation of [AttachmentFileUploader] class StreamAttachmentFileUploader implements AttachmentFileUploader { - final StreamChatClient _client; - /// Creates a new [StreamAttachmentFileUploader] instance. const StreamAttachmentFileUploader(this._client); + final StreamChatClient _client; + @override Future sendImage( AttachmentFile file, @@ -70,16 +70,28 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { ProgressCallback onSendProgress, CancelToken cancelToken, }) async { - final filename = file.path?.split('/')?.last; + final filename = file.path?.split('/')?.last ?? file.name; final mimeType = filename.mimeType; + + MultipartFile multiPartFile; + if (file.path != null) { + multiPartFile = await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ); + } else if (file.bytes != null) { + multiPartFile = MultipartFile.fromBytes( + file.bytes, + filename: filename, + contentType: mimeType, + ); + } + final response = await _client.post( '/channels/$channelType/$channelId/image', data: FormData.fromMap({ - 'file': await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ), + 'file': multiPartFile, }), onSendProgress: onSendProgress, cancelToken: cancelToken, @@ -95,16 +107,28 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { ProgressCallback onSendProgress, CancelToken cancelToken, }) async { - final filename = file.path?.split('/')?.last; + final filename = file.path?.split('/')?.last ?? file.name; final mimeType = filename.mimeType; + + MultipartFile multiPartFile; + if (file.path != null) { + multiPartFile = await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ); + } else if (file.bytes != null) { + multiPartFile = MultipartFile.fromBytes( + file.bytes, + filename: filename, + contentType: mimeType, + ); + } + final response = await _client.post( '/channels/$channelType/$channelId/file', data: FormData.fromMap({ - 'file': await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ), + 'file': multiPartFile, }), onSendProgress: onSendProgress, cancelToken: cancelToken, diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index c5d5322f9..d422dba12 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -1,36 +1,34 @@ import 'dart:async'; import 'dart:convert'; +import 'package:stream_chat/src/extensions/map_extension.dart'; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:pedantic/pedantic.dart' show unawaited; import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/api/channel.dart'; +import 'package:stream_chat/src/api/connection_status.dart'; +import 'package:stream_chat/src/api/requests.dart'; +import 'package:stream_chat/src/api/responses.dart'; import 'package:stream_chat/src/api/retry_policy.dart'; +import 'package:stream_chat/src/api/websocket.dart'; +import 'package:stream_chat/src/attachment_file_uploader.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/exceptions.dart'; import 'package:stream_chat/src/models/attachment_file.dart'; import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/models/channel_state.dart'; +import 'package:stream_chat/src/models/event.dart'; +import 'package:stream_chat/src/models/message.dart'; import 'package:stream_chat/src/models/own_user.dart'; +import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/src/platform_detector/platform_detector.dart'; import 'package:stream_chat/version.dart'; import 'package:uuid/uuid.dart'; -import 'attachment_file_uploader.dart'; -import 'api/channel.dart'; -import 'api/connection_status.dart'; -import 'api/requests.dart'; -import 'api/responses.dart'; -import 'api/websocket.dart'; -import 'db/chat_persistence_client.dart'; -import 'exceptions.dart'; -import 'models/channel_state.dart'; -import 'models/event.dart'; -import 'models/message.dart'; -import 'models/user.dart'; -import 'extensions/map_extension.dart'; - -/// Handler function used for logging records. Function requires a single [LogRecord] -/// as the only parameter. +/// Handler function used for logging records. Function requires a single +/// [LogRecord] as the only parameter. typedef LogHandlerFunction = void Function(LogRecord record); /// Used for decoding [Map] data to a generic type `T`. @@ -62,7 +60,9 @@ extension on PushProvider { /// The official Dart client for Stream Chat, /// a service for building chat applications. -/// This library can be used on any Dart project and on both mobile and web apps with Flutter. +/// This library can be used on any Dart project and on both mobile and web apps +/// with Flutter. +/// /// You can sign up for a Stream account at https://getstream.io/chat/ /// /// The Chat client will manage API call, event handling and manage the @@ -73,7 +73,8 @@ extension on PushProvider { /// ``` class StreamChatClient { /// Create a client instance with default options. - /// You should only create the client once and re-use it across your application. + /// You should only create the client once and re-use it across your + /// application. StreamChatClient( this.apiKey, { this.tokenProvider, @@ -86,12 +87,14 @@ class StreamChatClient { RetryPolicy retryPolicy, this.attachmentFileUploader, }) { - _retryPolicy ??= RetryPolicy( - retryTimeout: (StreamChatClient client, int attempt, ApiError error) => - Duration(seconds: 1 * attempt), - shouldRetry: (StreamChatClient client, int attempt, ApiError error) => - attempt < 5, - ); + _retryPolicy = retryPolicy ?? + RetryPolicy( + retryTimeout: + (StreamChatClient client, int attempt, ApiError error) => + Duration(seconds: 1 * attempt), + shouldRetry: (StreamChatClient client, int attempt, ApiError error) => + attempt < 5, + ); attachmentFileUploader ??= StreamAttachmentFileUploader(this); @@ -122,30 +125,39 @@ class StreamChatClient { /// This client state ClientState state; - /// By default the Chat client will write all messages with level Warn or Error to stdout. - /// During development you might want to enable more logging information, you can change the default log level when constructing the client. + /// By default the Chat client will write all messages with level Warn or + /// Error to stdout. + /// + /// During development you might want to enable more logging information, + /// you can change the default log level when constructing the client. /// /// ```dart - /// final client = StreamChatClient("stream-chat-api-key", logLevel: Level.INFO); + /// final client = StreamChatClient("stream-chat-api-key", + /// logLevel: Level.INFO); /// ``` final Level logLevel; /// Client specific logger instance. - /// Refer to the class [Logger] to learn more about the specific implementation. + /// Refer to the class [Logger] to learn more about the specific + /// implementation. final Logger logger = Logger.detached('📡'); /// 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. + /// 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 = StreamChatClient("stream-chat-api-key", logHandlerFunction: myLogHandlerFunction); + /// final client = StreamChatClient("stream-chat-api-key", + /// logHandlerFunction: myLogHandlerFunction); ///``` LogHandlerFunction logHandlerFunction; @@ -156,14 +168,18 @@ class StreamChatClient { /// Your project Stream Chat base url. final String baseURL; - /// A function in which you send a request to your own backend to get a Stream Chat API token. + /// A function in which you send a request to your own backend to get a Stream + /// Chat API token. + /// /// The token will be the return value of the function. - /// It's used by the client to refresh the token once expired or to connect the user without a predefined token using [connectUserWithProvider]. + /// It's used by the client to refresh the token once expired or to connect + /// the user without a predefined token using [connectUserWithProvider]. final TokenProvider tokenProvider; /// [Dio] httpClient - /// It's be chosen because it's easy to use and supports interesting features out of the box - /// (Interceptors, Global configuration, FormData, File downloading etc.) + /// It's be chosen because it's easy to use and supports interesting features + /// out of the box (Interceptors, Global configuration, FormData, + /// File downloading etc.) @visibleForTesting Dio httpClient = Dio(); @@ -234,7 +250,7 @@ class StreamChatClient { (options.data is Map || options.data == null)) { options.data = { 'connection_id': _connectionId, - ...(options.data ?? {}), + ...options.data ?? {}, }; } @@ -278,7 +294,7 @@ class StreamChatClient { await _disconnect(); final newToken = await tokenProvider(userId); - await Future.delayed(Duration(seconds: 4)); + await Future.delayed(const Duration(seconds: 4)); token = newToken; httpClient.unlock(); @@ -312,7 +328,10 @@ class StreamChatClient { }; return (LogRecord record) { print( - '(${record.time}) ${levelEmojiMapper[record.level.name] ?? record.level.name} ${record.loggerName} ${record.message}'); + '(${record.time}) ' + '${levelEmojiMapper[record.level.name] ?? record.level.name} ' + '${record.loggerName} ${record.message}', + ); if (record.stackTrace != null) { print(record.stackTrace); } @@ -321,11 +340,10 @@ class StreamChatClient { Logger _detachedLogger( String name, - ) { - return Logger.detached(name) - ..level = logLevel - ..onRecord.listen(logHandlerFunction ?? _getDefaultLogHandler()); - } + ) => + Logger.detached(name) + ..level = logLevel + ..onRecord.listen(logHandlerFunction ?? _getDefaultLogHandler()); void _setupLogger() { logger.level = logLevel; @@ -350,7 +368,7 @@ class StreamChatClient { Map get _httpHeaders => { 'Authorization': token, 'stream-auth-type': _authType, - 'x-stream-client': _userAgent, + 'X-Stream-Client': _userAgent, 'Content-Encoding': 'gzip', }; @@ -386,7 +404,8 @@ class StreamChatClient { /// Set the current user using the [tokenProvider] to fetch the token. /// It returns a [Future] that resolves when the connection is setup. @Deprecated( - 'Use `connectUserWithProvider` instead. Will be removed in Future releases') + 'Use `connectUserWithProvider` instead. Will be removed in Future releases', + ) Future setUserWithProvider(User user) => connectUserWithProvider(user); /// Connects the current user using the [tokenProvider] to fetch the token. @@ -470,7 +489,7 @@ class StreamChatClient { 'api_key': apiKey, 'authorization': token, 'stream-auth-type': _authType, - 'x-stream-client': _userAgent, + 'X-Stream-Client': _userAgent, }, connectPayload: { 'user_id': state.user.id, @@ -491,7 +510,8 @@ class StreamChatClient { if (status == ConnectionStatus.connected && state.channels?.isNotEmpty == true) { - unawaited(queryChannelsOnline(filter: { + // ignore: unawaited_futures + queryChannelsOnline(filter: { 'cid': { '\$in': state.channels.keys.toList(), }, @@ -503,7 +523,7 @@ class StreamChatClient { online: true, )); }, - )); + ); } else { _synced = false; } @@ -521,6 +541,7 @@ class StreamChatClient { }).catchError((err, stacktrace) { logger.severe('error connecting ws', err, stacktrace); if (err is Map) { + // ignore: only_throw_errors throw err; } }); @@ -558,13 +579,12 @@ class StreamChatClient { res.events.sort((a, b) => a.createdAt.compareTo(b.createdAt)); res.events.forEach((element) { - logger.fine('element.type: ${element.type}'); - logger.fine('element.message.text: ${element.message?.text}'); + logger + ..fine('element.type: ${element.type}') + ..fine('element.message.text: ${element.message?.text}'); }); - res.events.forEach((event) { - handleEvent(event); - }); + res.events.forEach(handleEvent); await chatPersistenceClient?.updateLastSyncAt(DateTime.now()); _synced = true; @@ -573,9 +593,7 @@ class StreamChatClient { } } - String _asMap(sort) { - return sort?.map((s) => s.toJson().toString())?.join(''); - } + String _asMap(sort) => sort?.map((s) => s.toJson().toString())?.join(''); final _queryChannelsStreams = >>{}; @@ -584,40 +602,41 @@ class StreamChatClient { Map filter, List> sort, Map options, - PaginationParams paginationParams = const PaginationParams(limit: 10), + PaginationParams paginationParams = const PaginationParams(), int messageLimit, - bool preferOffline = false, bool waitForConnect = true, }) async* { final hash = base64.encode(utf8.encode( - '$filter${_asMap(sort)}$options${paginationParams?.toJson()}$messageLimit$preferOffline', + '$filter${_asMap(sort)}$options${paginationParams?.toJson()}' + '$messageLimit', )); if (_queryChannelsStreams.containsKey(hash)) { yield await _queryChannelsStreams[hash]; } else { - if (preferOffline) { - final channels = await queryChannelsOffline( - filter: filter, - sort: sort, - paginationParams: paginationParams, - ); - if (channels.isNotEmpty) yield channels; - } - - final newQueryChannelsFuture = queryChannelsOnline( + final channels = await queryChannelsOffline( filter: filter, sort: sort, - options: options, paginationParams: paginationParams, - messageLimit: messageLimit, - ).whenComplete(() { - _queryChannelsStreams.remove(hash); - }); + ); + if (channels.isNotEmpty) yield channels; + + if (wsConnectionStatus == ConnectionStatus.connected) { + final newQueryChannelsFuture = queryChannelsOnline( + filter: filter, + sort: sort, + options: options, + paginationParams: paginationParams, + messageLimit: messageLimit, + waitForConnect: waitForConnect, + ).whenComplete(() { + _queryChannelsStreams.remove(hash); + }); - _queryChannelsStreams[hash] = newQueryChannelsFuture; + _queryChannelsStreams[hash] = newQueryChannelsFuture; - yield await newQueryChannelsFuture; + yield await newQueryChannelsFuture; + } } } @@ -627,7 +646,7 @@ class StreamChatClient { List> sort, Map options, int messageLimit, - PaginationParams paginationParams = const PaginationParams(limit: 10), + PaginationParams paginationParams = const PaginationParams(), bool waitForConnect = true, }) async { if (waitForConnect) { @@ -650,7 +669,7 @@ class StreamChatClient { 'presence': false, }; - var payload = { + final payload = { 'filter_conditions': filter, 'sort': sort, }; @@ -682,9 +701,12 @@ class StreamChatClient { ); if ((res.channels ?? []).isEmpty && (paginationParams?.offset ?? 0) == 0) { - logger.warning('''We could not find any channel for this query. + logger.warning( + ''' + We could not find any channel for this query. Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial - If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart'''); + If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart''', + ); return []; } @@ -715,7 +737,7 @@ class StreamChatClient { Future> queryChannelsOffline({ @required Map filter, @required List> sort, - PaginationParams paginationParams = const PaginationParams(limit: 10), + PaginationParams paginationParams = const PaginationParams(), }) async { final offlineChannels = await chatPersistenceClient?.getChannelStates( filter: filter, @@ -771,6 +793,7 @@ class StreamChatClient { ); return response; } on DioError catch (error) { + // ignore: only_throw_errors throw _parseError(error); } } @@ -791,6 +814,7 @@ class StreamChatClient { ); return response; } on DioError catch (error) { + // ignore: only_throw_errors throw _parseError(error); } } @@ -809,6 +833,7 @@ class StreamChatClient { ); return response; } on DioError catch (error) { + // ignore: only_throw_errors throw _parseError(error); } } @@ -827,6 +852,7 @@ class StreamChatClient { ); return response; } on DioError catch (error) { + // ignore: only_throw_errors throw _parseError(error); } } @@ -845,6 +871,7 @@ class StreamChatClient { ); return response; } on DioError catch (error) { + // ignore: only_throw_errors throw _parseError(error); } } @@ -864,8 +891,8 @@ class StreamChatClient { String get _authType => _anonymous ? 'anonymous' : 'jwt'; - String get _userAgent => - 'stream-chat-dart-client-${CurrentPlatform.name}-${PACKAGE_VERSION.split('+')[0]}'; + String get _userAgent => 'stream-chat-dart-client-${CurrentPlatform.name}-' + '${PACKAGE_VERSION.split('+')[0]}'; Map get _commonQueryParams => { 'user_id': state.user?.id, @@ -873,14 +900,15 @@ class StreamChatClient { 'connection_id': _connectionId, }; - /// Set the current user with an anonymous id, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. + /// Set the current user with an anonymous id, this triggers a connection to + /// the API. It returns a [Future] that resolves when the connection is setup. @Deprecated( 'Use `connectAnonymousUser` instead. Will be removed in Future releases') Future setAnonymousUser() => connectAnonymousUser(); - /// Connects the current user with an anonymous id, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. + /// Connects the current user with an anonymous id, this triggers a connection + /// to the API. It returns a [Future] that resolves when the connection is + /// setup. Future connectAnonymousUser() async { if (_connectCompleter != null && !_connectCompleter.isCompleted) { logger.warning('Already connecting'); @@ -923,14 +951,14 @@ class StreamChatClient { } /// Closes the websocket connection and resets the client - /// If [flushChatPersistence] is true the client deletes all offline user's data - /// If [clearUser] is true the client unsets the current user + /// If [flushChatPersistence] is true the client deletes all offline + /// user's data. If [clearUser] is true the client unsets the current user Future disconnect({ bool flushChatPersistence = false, bool clearUser = false, }) async { - logger.info( - 'Disconnecting flushOfflineStorage: $flushChatPersistence; clearUser: $clearUser'); + logger.info('Disconnecting flushOfflineStorage: $flushChatPersistence; ' + 'clearUser: $clearUser'); await chatPersistenceClient?.disconnect(flush: flushChatPersistence); chatPersistenceClient = null; @@ -966,9 +994,7 @@ class StreamChatClient { final payload = { 'filter_conditions': filter ?? {}, 'sort': sort, - }; - - payload.addAll(defaultOptions); + }..addAll(defaultOptions); if (pagination != null) { payload.addAll(pagination.toJson()); @@ -1016,7 +1042,7 @@ class StreamChatClient { ); } return true; - }()); + }(), 'Check incoming params.'); final payload = { 'filter_conditions': filters, @@ -1041,15 +1067,14 @@ class StreamChatClient { String channelType, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) { - return attachmentFileUploader.sendFile( - file, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - } + }) => + attachmentFileUploader.sendFile( + file, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); /// Send a [image] to the [channelId] of type [channelType] Future sendImage( @@ -1058,15 +1083,14 @@ class StreamChatClient { String channelType, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) { - return attachmentFileUploader.sendImage( - image, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - } + }) => + attachmentFileUploader.sendImage( + image, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); /// Delete a file from this channel Future deleteFile( @@ -1074,14 +1098,13 @@ class StreamChatClient { String channelId, String channelType, { CancelToken cancelToken, - }) { - return attachmentFileUploader.deleteFile( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - } + }) => + attachmentFileUploader.deleteFile( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); /// Delete an image from this channel Future deleteImage( @@ -1089,14 +1112,13 @@ class StreamChatClient { String channelId, String channelType, { CancelToken cancelToken, - }) { - return attachmentFileUploader.deleteImage( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - } + }) => + attachmentFileUploader.deleteImage( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); /// Add a device for Push Notifications. Future addDevice(String id, PushProvider pushProvider) async { @@ -1146,9 +1168,8 @@ class StreamChatClient { } /// Update or Create the given user object. - Future updateUser(User user) async { - return updateUsers([user]); - } + Future updateUser(User user) async => + updateUsers([user]); /// Batch update a list of users Future updateUsers(List users) async { @@ -1197,23 +1218,21 @@ class StreamChatClient { Future shadowBan( String targetID, [ Map options = const {}, - ]) async { - return banUser(targetID, { - 'shadow': true, - ...options, - }); - } + ]) async => + banUser(targetID, { + 'shadow': true, + ...options, + }); /// Removes shadow ban from a user Future removeShadowBan( String targetID, [ Map options = const {}, - ]) async { - return unbanUser(targetID, { - 'shadow': true, - ...options, - }); - } + ]) async => + unbanUser(targetID, { + 'shadow': true, + ...options, + }); /// Mutes a user Future muteUser(String targetID) async { @@ -1312,7 +1331,7 @@ class StreamChatClient { throw ArgumentError('Invalid timeout or Expiration date'); } return true; - }()); + }(), 'Check whether time out is valid'); DateTime pinExpires; if (timeoutOrExpirationDate is DateTime) { @@ -1328,15 +1347,12 @@ class StreamChatClient { } /// Unpins provided message - Future unpinMessage(Message message) { - return updateMessage(message.copyWith(pinned: false)); - } + Future unpinMessage(Message message) => + updateMessage(message.copyWith(pinned: false)); } /// The class that handles the state of the channel listening to the events class ClientState { - final _subscriptions = []; - /// Creates a new instance listening to events and updating the state ClientState(this._client) { _subscriptions.addAll([ @@ -1358,16 +1374,12 @@ class ClientState { .on() .where((event) => event.unreadChannels != null) .map((e) => e.unreadChannels) - .listen((unreadChannels) { - _unreadChannelsController.add(unreadChannels); - }), + .listen(_unreadChannelsController.add), _client .on() .where((event) => event.totalUnreadCount != null) .map((e) => e.totalUnreadCount) - .listen((totalUnreadCount) { - _totalUnreadCountController.add(totalUnreadCount); - }), + .listen(_totalUnreadCountController.add), ]); _listenChannelDeleted(); @@ -1377,6 +1389,8 @@ class ClientState { _listenUserUpdated(); } + final _subscriptions = []; + /// Used internally for optimistic update of unread count set totalUnreadCount(int unreadCount) { _totalUnreadCountController?.add(unreadCount ?? 0); 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 da99f34e4..d90872a2f 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -101,18 +101,17 @@ abstract class ChatPersistenceClient { Future updateChannelQueries( Map filter, List cids, + // ignore: avoid_positional_boolean_parameters bool clearQueryCache, ); /// Remove a message by [messageId] - Future deleteMessageById(String messageId) { - return deleteMessageByIds([messageId]); - } + Future deleteMessageById(String messageId) => + deleteMessageByIds([messageId]); /// Remove a pinned message by [messageId] - Future deletePinnedMessageById(String messageId) { - return deletePinnedMessageByIds([messageId]); - } + Future deletePinnedMessageById(String messageId) => + deletePinnedMessageByIds([messageId]); /// Remove a message by [messageIds] Future deleteMessageByIds(List messageIds); @@ -121,14 +120,11 @@ abstract class ChatPersistenceClient { Future deletePinnedMessageByIds(List messageIds); /// Remove a message by channel [cid] - Future deleteMessageByCid(String cid) { - return deleteMessageByCids([cid]); - } + Future deleteMessageByCid(String cid) => deleteMessageByCids([cid]); /// Remove a pinned message by channel [cid] - Future deletePinnedMessageByCid(String cid) { - return deletePinnedMessageByCids([cid]); - } + Future deletePinnedMessageByCid(String cid) async => + deletePinnedMessageByCids([cid]); /// Remove a message by message [cids] Future deleteMessageByCids(List cids); @@ -175,9 +171,8 @@ abstract class ChatPersistenceClient { Future deleteMembersByCids(List cids); /// Update the channel state data using [channelState] - Future updateChannelState(ChannelState channelState) { - return updateChannelStates([channelState]); - } + Future updateChannelState(ChannelState channelState) => + updateChannelStates([channelState]); /// Update list of channel states Future updateChannelStates(List channelStates) async { @@ -195,31 +190,31 @@ abstract class ChatPersistenceClient { deleteMembers, ]); - final channels = channelStates.map((it) { - return it.channel; - }).where((it) => it != null); + final channels = + channelStates.map((it) => it.channel).where((it) => it != null); - final reactions = channelStates.expand((it) => it.messages).expand((it) { - return [ - if (it.ownReactions != null) - ...it.ownReactions.where((r) => r.userId != null), - if (it.latestReactions != null) - ...it.latestReactions.where((r) => r.userId != null) - ]; - }).where((it) => it != null); + final reactions = channelStates + .expand((it) => it.messages) + .expand((it) => [ + if (it.ownReactions != null) + ...it.ownReactions.where((r) => r.userId != null), + if (it.latestReactions != null) + ...it.latestReactions.where((r) => r.userId != null) + ]) + .where((it) => it != null); final users = channelStates .map((cs) => [ cs.channel?.createdBy, - ...cs.messages?.map((m) { - return [ - m.user, - if (m.latestReactions != null) - ...m.latestReactions.map((r) => r.user), - if (m.ownReactions != null) - ...m.ownReactions.map((r) => r.user), - ]; - })?.expand((v) => v), + ...cs.messages + ?.map((m) => [ + m.user, + if (m.latestReactions != null) + ...m.latestReactions.map((r) => r.user), + if (m.ownReactions != null) + ...m.ownReactions.map((r) => r.user), + ]) + ?.expand((v) => v), if (cs.read != null) ...cs.read.map((r) => r.user), if (cs.members != null) ...cs.members.map((m) => m.user), ]) diff --git a/packages/stream_chat/lib/src/debounce.dart b/packages/stream_chat/lib/src/debounce.dart deleted file mode 100644 index f1cf05a45..000000000 --- a/packages/stream_chat/lib/src/debounce.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:async'; - -import 'package:meta/meta.dart'; - -/// Map of timeouts being debounced -Map timeouts = {}; - -/// Runs a function avoiding calling it too many times in a [timeoutMS] window -void debounce({ - @required Duration timeout, - @required Function target, - List positionalArguments, - Map namedArguments, -}) { - if (timeouts.containsKey(target)) { - timeouts[target].cancel(); - } - - final timer = Timer(timeout, () { - Function.apply( - target, - positionalArguments, - namedArguments, - ); - }); - - timeouts[target] = timer; -} diff --git a/packages/stream_chat/lib/src/exceptions.dart b/packages/stream_chat/lib/src/exceptions.dart index 9ba27e795..60dd6bdff 100644 --- a/packages/stream_chat/lib/src/exceptions.dart +++ b/packages/stream_chat/lib/src/exceptions.dart @@ -2,6 +2,13 @@ import 'dart:convert'; /// Exception related to api calls class ApiError extends Error { + /// Creates a new ApiError instance using the response body and status code + ApiError(this.body, this.status) : jsonData = _decode(body) { + if (jsonData != null && jsonData.containsKey('code')) { + _code = jsonData['code']; + } + } + /// Raw body of the response final String body; @@ -26,13 +33,6 @@ class ApiError extends Error { } } - /// Creates a new ApiError instance using the response body and status code - ApiError(this.body, this.status) : jsonData = _decode(body) { - if (jsonData != null && jsonData.containsKey('code')) { - _code = jsonData['code']; - } - } - @override bool operator ==(Object other) => identical(this, other) || @@ -48,7 +48,6 @@ class ApiError extends Error { body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode; @override - String toString() { - return 'ApiError{body: $body, jsonData: $jsonData, status: $status, code: $_code}'; - } + String toString() => 'ApiError{body: $body, jsonData: $jsonData, ' + 'status: $status, code: $_code}'; } diff --git a/packages/stream_chat/lib/src/extensions/map_extension.dart b/packages/stream_chat/lib/src/extensions/map_extension.dart index 3028c20a2..1a3376ad2 100644 --- a/packages/stream_chat/lib/src/extensions/map_extension.dart +++ b/packages/stream_chat/lib/src/extensions/map_extension.dart @@ -1,7 +1,6 @@ /// Useful extension functions for [Map] extension MapX on Map { /// Returns a new map with null keys or values removed - Map get nullProtected { - return {...this}..removeWhere((key, value) => key == null || value == null); - } + Map get nullProtected => + {...this}..removeWhere((key, value) => key == null || value == null); } diff --git a/packages/stream_chat/lib/src/extensions/rate_limit.dart b/packages/stream_chat/lib/src/extensions/rate_limit.dart new file mode 100644 index 000000000..c9f5934fb --- /dev/null +++ b/packages/stream_chat/lib/src/extensions/rate_limit.dart @@ -0,0 +1,335 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:async' show Timer; +import 'dart:math' as math; + +/// Useful rate limiter extensions for [Function] class. +extension RateLimit on Function { + /// Converts this into a [Debounce] function. + Debounce debounced( + Duration wait, { + bool leading = false, + bool trailing = true, + Duration maxWait, + }) => + Debounce( + this, + wait, + leading: leading, + trailing: trailing, + maxWait: maxWait, + ); + + /// Converts this into a [Throttle] function. + Throttle throttled( + Duration wait, { + bool leading = true, + bool trailing = true, + }) => + Throttle( + this, + wait, + leading: leading, + trailing: trailing, + ); +} + +/// TopLevel lambda to create [Debounce] functions. +Debounce debounce( + Function func, + Duration wait, { + bool leading = false, + bool trailing = true, + Duration maxWait, +}) => + Debounce( + func, + wait, + leading: leading, + trailing: trailing, + maxWait: maxWait, + ); + +/// TopLevel lambda to create [Throttle] functions. +Throttle throttle( + Function func, + Duration wait, { + bool leading = true, + bool trailing = true, +}) => + Throttle( + func, + wait, + leading: leading, + trailing: trailing, + ); + +/// Creates a debounced function that delays invoking `func` until after `wait` +/// milliseconds have elapsed since the last time the debounced function was +/// invoked. The debounced function comes with a [Debounce.cancel] method to cancel +/// delayed `func` invocations and a [Debounce.flush] method to immediately invoke them. +/// Provide `leading` and/or `trailing` to indicate whether `func` should be +/// invoked on the `leading` and/or `trailing` edge of the `wait` interval. +/// The `func` is invoked with the last arguments provided to the [call] +/// function. Subsequent calls to the debounced function return the result of +/// the last `func` invocation. +/// +/// **Note:** If `leading` and `trailing` options are `true`, `func` is +/// invoked on the trailing edge of the timeout only if the debounced function +/// is invoked more than once during the `wait` timeout. +/// +/// If `wait` is [Duration.zero] and `leading` is `false`, +/// `func` invocation is deferred until the next tick. +/// +/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) +/// for details over the differences between [Debounce] and [Throttle]. +/// +/// Some examples: +/// +/// Avoid calling costly network calls when user is typing something. +/// ```dart +/// void fetchData(String query) async { +/// final data = api.getData(query); +/// doSomethingWithTheData(data); +/// } +/// +/// final debouncedFetchData = Debounce( +/// fetchData, +/// const Duration(milliseconds: 350), +/// ); +/// +/// void onSearchQueryChanged(query) { +/// debouncedFetchData(query); +/// } +/// ``` +/// +/// Cancel the trailing debounced invocation. +/// ```dart +/// void dispose() { +/// debounced.cancel(); +/// } +/// ``` +/// +/// Check for pending invocations. +/// ```dart +/// final status = debounced.isPending ? "Pending..." : "Ready"; +/// ``` +class Debounce { + /// Creates a new instance of [Debounce]. + Debounce( + this._func, + Duration wait, { + bool leading = false, + bool trailing = true, + Duration maxWait, + }) : _leading = leading, + _trailing = trailing, + _wait = wait?.inMilliseconds ?? 0, + _maxing = maxWait != null { + if (_maxing) { + _maxWait = math.max(maxWait.inMilliseconds, _wait); + } + } + + final Function _func; + final bool _leading; + final bool _trailing; + final int _wait; + final bool _maxing; + + int _maxWait; + List _lastArgs; + Map _lastNamedArgs; + Timer _timer; + int _lastCallTime; + Object _result; + int _lastInvokeTime = 0; + + Object _invokeFunc(int time) { + final args = _lastArgs; + final namedArgs = _lastNamedArgs; + _lastArgs = _lastNamedArgs = null; + _lastInvokeTime = time; + return _result = Function.apply(_func, args, namedArgs); + } + + Timer _startTimer(Function pendingFunc, int wait) => + Timer(Duration(milliseconds: wait), pendingFunc); + + bool _shouldInvoke(int time) { + final timeSinceLastCall = time - (_lastCallTime ?? double.nan); + final timeSinceLastInvoke = time - _lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return _lastCallTime == null || + (timeSinceLastCall >= _wait) || + (timeSinceLastCall < 0) || + (_maxing && timeSinceLastInvoke >= _maxWait); + } + + Object _trailingEdge(int time) { + _timer = null; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (_trailing && _lastArgs != null) { + return _invokeFunc(time); + } + _lastArgs = _lastNamedArgs = null; + return _result; + } + + int _remainingWait(int time) { + final timeSinceLastCall = time - _lastCallTime; + final timeSinceLastInvoke = time - _lastInvokeTime; + final timeWaiting = _wait - timeSinceLastCall; + + return _maxing + ? math.min(timeWaiting, _maxWait - timeSinceLastInvoke) + : timeWaiting; + } + + void _timerExpired() { + final time = DateTime.now().millisecondsSinceEpoch; + if (_shouldInvoke(time)) { + _trailingEdge(time); + } else { + // Restart the timer. + _timer = _startTimer(_timerExpired, _remainingWait(time)); + } + } + + Object _leadingEdge(int time) { + // Reset any `maxWait` timer. + _lastInvokeTime = time; + // Start the timer for the trailing edge. + _timer = _startTimer(_timerExpired, _wait); + // Invoke the leading edge. + return _leading ? _invokeFunc(time) : _result; + } + + /// Cancels all the remaining delayed functions. + void cancel() { + _timer?.cancel(); + _lastInvokeTime = 0; + _lastArgs = _lastNamedArgs = _lastCallTime = _timer = null; + } + + /// Immediately invokes all the remaining delayed functions. + Object flush() { + final now = DateTime.now().millisecondsSinceEpoch; + return _timer == null ? _result : _trailingEdge(now); + } + + /// True if there are functions remaining to get invoked. + bool get isPending => _timer != null; + + /// Calls/invokes this class like a function. + /// Pass [args] and [namedArgs] to be used while invoking [_func]. + Object call( + List args, { + Map namedArgs, + }) { + final time = DateTime.now().millisecondsSinceEpoch; + final isInvoking = _shouldInvoke(time); + + _lastArgs = args; + _lastNamedArgs = namedArgs; + _lastCallTime = time; + + if (isInvoking) { + if (_timer == null) { + return _leadingEdge(_lastCallTime); + } + if (_maxing) { + // Handle invocations in a tight loop. + _timer = _startTimer(_timerExpired, _wait); + return _invokeFunc(_lastCallTime); + } + } + _timer ??= _startTimer(_timerExpired, _wait); + return _result; + } +} + +/// Creates a throttled function that only invokes `func` at most once per +/// every `wait` milliseconds. The throttled function comes with a [Throttle.cancel] +/// method to cancel delayed `func` invocations and a [Throttle.flush] method to +/// immediately invoke them. Provide `leading` and/or `trailing` to indicate +/// whether `func` should be invoked on the `leading` and/or `trailing` edge of the `wait` timeout. +/// The `func` is invoked with the last arguments provided to the +/// throttled function. Subsequent calls to the throttled function return the +/// result of the last `func` invocation. +/// +/// **Note:** If `leading` and `trailing` options are `true`, `func` is +/// invoked on the trailing edge of the timeout only if the throttled function +/// is invoked more than once during the `wait` timeout. +/// +/// If `wait` is [Duration.zero] and `leading` is `false`, `func` invocation is deferred +/// until the next tick. +/// +/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) +/// for details over the differences between [Throttle] and [Debounce]. +/// +/// Some examples: +/// +/// Avoid excessively rebuilding UI progress while uploading data to server. +/// ```dart +/// void updateUI(Data data) { +/// updateProgress(data); +/// } +/// +/// final throttledUpdateUI = Throttle( +/// updateUI, +/// const Duration(milliseconds: 350), +/// ); +/// +/// void onUploadProgressChanged(progress) { +/// throttledUpdateUI(progress); +/// } +/// ``` +/// +/// Cancel the trailing throttled invocation. +/// ```dart +/// void dispose() { +/// throttled.cancel(); +/// } +/// ``` +/// +/// Check for pending invocations. +/// ```dart +/// final status = throttled.isPending ? "Pending..." : "Ready"; +/// ``` +class Throttle { + /// Creates a new instance of [Throttle] + Throttle( + Function func, + Duration wait, { + bool leading = true, + bool trailing = true, + }) : _debounce = Debounce( + func, + wait, + leading: leading, + trailing: trailing, + maxWait: wait, + ); + + final Debounce _debounce; + + /// Cancels all the remaining delayed functions. + void cancel() => _debounce.cancel(); + + /// Immediately invokes all the remaining delayed functions. + Object flush() => _debounce.flush(); + + /// True if there are functions remaining to get invoked. + bool get isPending => _debounce.isPending; + + /// Calls/invokes this class like a function. + /// Pass [args] and [namedArgs] to be used while invoking `func`. + Object call(List args, {Map namedArgs}) => + _debounce.call(args, namedArgs: namedArgs); +} diff --git a/packages/stream_chat/lib/src/models/action.dart b/packages/stream_chat/lib/src/models/action.dart index 34bb8f7e5..62d0f104f 100644 --- a/packages/stream_chat/lib/src/models/action.dart +++ b/packages/stream_chat/lib/src/models/action.dart @@ -5,6 +5,12 @@ part 'action.g.dart'; /// The class that contains the information about an action @JsonSerializable() class Action { + /// Constructor used for json serialization + Action({this.name, this.style, this.text, this.type, this.value}); + + /// Create a new instance from a json + factory Action.fromJson(Map json) => _$ActionFromJson(json); + /// The name of the action final String name; @@ -20,12 +26,6 @@ class Action { /// The value of the action final String value; - /// Constructor used for json serialization - Action({this.name, this.style, this.text, this.type, this.value}); - - /// Create a new instance from a json - factory Action.fromJson(Map json) => _$ActionFromJson(json); - /// Serialize to json Map toJson() => _$ActionToJson(this); } diff --git a/packages/stream_chat/lib/src/models/attachment.dart b/packages/stream_chat/lib/src/models/attachment.dart index e2e81cd93..de5149db2 100644 --- a/packages/stream_chat/lib/src/models/attachment.dart +++ b/packages/stream_chat/lib/src/models/attachment.dart @@ -1,18 +1,61 @@ // ignore_for_file: public_member_api_docs import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/models/action.dart'; import 'package:stream_chat/src/models/attachment_file.dart'; +import 'package:stream_chat/src/models/serialization.dart'; import 'package:uuid/uuid.dart'; -import 'action.dart'; -import 'serialization.dart'; - part 'attachment.g.dart'; /// The class that contains the information about an attachment @JsonSerializable(includeIfNull: false) class Attachment { - ///The attachment type based on the URL resource. This can be: audio, image or video + /// Constructor used for json serialization + Attachment({ + String id, + this.type, + this.titleLink, + String title, + this.thumbUrl, + this.text, + this.pretext, + this.ogScrapeUrl, + this.imageUrl, + this.footerIcon, + this.footer, + this.fields, + this.fallback, + this.color, + this.authorName, + this.authorLink, + this.authorIcon, + this.assetUrl, + this.actions, + this.extraData, + this.file, + UploadState uploadState, + }) : id = id ?? Uuid().v4(), + title = title ?? file?.name, + localUri = file?.path != null ? Uri.parse(file.path) : null { + this.uploadState = uploadState ?? + ((assetUrl != null || imageUrl != null) + ? const UploadState.success() + : const UploadState.preparing()); + } + + /// Create a new instance from a json + factory Attachment.fromJson(Map json) => + _$AttachmentFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Create a new instance from a db data + factory Attachment.fromData(Map json) => + _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot( + json, topLevelFields + dbSpecificTopLevelFields)); + + ///The attachment type based on the URL resource. This can be: audio, + ///image or video final String type; ///The link to which the attachment message points to. @@ -21,10 +64,12 @@ class Attachment { /// The attachment title final String title; - /// The URL to the attached file thumbnail. You can use this to represent the attached link. + /// The URL to the attached file thumbnail. You can use this to represent the + /// attached link. final String thumbUrl; - /// The attachment text. It will be displayed in the channel next to the original message. + /// The attachment text. It will be displayed in the channel next to the + /// original message. final String text; /// Optional text that appears above the attachment block @@ -33,7 +78,8 @@ class Attachment { /// The original URL that was used to scrape this attachment. final String ogScrapeUrl; - /// The URL to the attached image. This is present for URL pointing to an image article (eg. Unsplash) + /// The URL to the attached image. This is present for URL pointing to an + /// image article (eg. Unsplash) final String imageUrl; final String footerIcon; final String footer; @@ -100,56 +146,11 @@ class Attachment { 'file', ]; - /// Constructor used for json serialization - Attachment({ - String id, - this.type, - this.titleLink, - String title, - this.thumbUrl, - this.text, - this.pretext, - this.ogScrapeUrl, - this.imageUrl, - this.footerIcon, - this.footer, - this.fields, - this.fallback, - this.color, - this.authorName, - this.authorLink, - this.authorIcon, - this.assetUrl, - this.actions, - this.extraData, - this.file, - UploadState uploadState, - }) : id = id ?? Uuid().v4(), - title = title ?? file?.name, - localUri = file?.path != null ? Uri.parse(file.path) : null { - this.uploadState = uploadState ?? - ((assetUrl != null || imageUrl != null) - ? UploadState.success() - : UploadState.preparing()); - } - - /// Create a new instance from a json - factory Attachment.fromJson(Map json) { - return _$AttachmentFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - } - /// Serialize to json Map toJson() => Serialization.moveFromExtraDataToRoot( _$AttachmentToJson(this), topLevelFields) ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); - /// Create a new instance from a db data - factory Attachment.fromData(Map json) { - return _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot( - json, topLevelFields + dbSpecificTopLevelFields)); - } - /// Serialize to db data Map toData() => Serialization.moveFromExtraDataToRoot( _$AttachmentToJson(this), topLevelFields + dbSpecificTopLevelFields); diff --git a/packages/stream_chat/lib/src/models/attachment_file.dart b/packages/stream_chat/lib/src/models/attachment_file.dart index f1c10d5ca..bae7810f1 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/models/attachment_file.dart @@ -1,10 +1,9 @@ import 'dart:typed_data'; -import 'package:meta/meta.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:meta/meta.dart'; part 'attachment_file.freezed.dart'; - part 'attachment_file.g.dart'; /// Union class to hold various [UploadState] of a attachment. @@ -57,8 +56,12 @@ class AttachmentFile { this.size, }); - /// The absolute path for a cached copy of this file. It can be used to create a - /// file instance with a descriptor for the given path. + /// Create a new instance from a json + factory AttachmentFile.fromJson(Map json) => + _$AttachmentFileFromJson(json); + + /// The absolute path for a cached copy of this file. It can be used to + /// create a file instance with a descriptor for the given path. /// ``` /// final File myFile = File(platformFile.path); /// ``` @@ -67,8 +70,8 @@ class AttachmentFile { /// File name including its extension. final String name; - /// Byte data for this file. Particularly useful if you want to manipulate its data - /// or easily upload to somewhere else. + /// Byte data for this file. Particularly useful if you want to manipulate + /// its data or easily upload to somewhere else. @JsonKey(toJson: _toString, fromJson: _fromString) final Uint8List bytes; @@ -78,11 +81,6 @@ class AttachmentFile { /// File extension for this file. String get extension => name?.split('.')?.last; - /// Create a new instance from a json - factory AttachmentFile.fromJson(Map json) { - return _$AttachmentFileFromJson(json); - } - /// Serialize to json Map toJson() => _$AttachmentFileToJson(this); } diff --git a/packages/stream_chat/lib/src/models/channel_config.dart b/packages/stream_chat/lib/src/models/channel_config.dart index e455930f1..3541573e9 100644 --- a/packages/stream_chat/lib/src/models/channel_config.dart +++ b/packages/stream_chat/lib/src/models/channel_config.dart @@ -1,12 +1,34 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'command.dart'; - +import 'package:stream_chat/src/models/command.dart'; part 'channel_config.g.dart'; /// The class that contains the information about the configuration of a channel @JsonSerializable() class ChannelConfig { + /// Constructor used for json serialization + ChannelConfig({ + this.automod, + this.commands, + this.connectEvents, + this.createdAt, + this.updatedAt, + this.maxMessageLength, + this.messageRetention, + this.mutes, + this.name, + this.reactions, + this.readEvents, + this.replies, + this.search, + this.typingEvents, + this.uploads, + this.urlEnrichment, + }); + + /// Create a new instance from a json + factory ChannelConfig.fromJson(Map json) => + _$ChannelConfigFromJson(json); + /// Moderation configuration final String automod; @@ -55,30 +77,6 @@ class ChannelConfig { /// True if urls appears as attachments final bool urlEnrichment; - /// Constructor used for json serialization - ChannelConfig({ - this.automod, - this.commands, - this.connectEvents, - this.createdAt, - this.updatedAt, - this.maxMessageLength, - this.messageRetention, - this.mutes, - this.name, - this.reactions, - this.readEvents, - this.replies, - this.search, - this.typingEvents, - this.uploads, - this.urlEnrichment, - }); - - /// Create a new instance from a json - factory ChannelConfig.fromJson(Map json) => - _$ChannelConfigFromJson(json); - /// Serialize to json Map toJson() => _$ChannelConfigToJson(this); } diff --git a/packages/stream_chat/lib/src/models/channel_model.dart b/packages/stream_chat/lib/src/models/channel_model.dart index 046e29c57..62ea18535 100644 --- a/packages/stream_chat/lib/src/models/channel_model.dart +++ b/packages/stream_chat/lib/src/models/channel_model.dart @@ -1,14 +1,35 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'channel_config.dart'; -import 'serialization.dart'; -import 'user.dart'; +import 'package:stream_chat/src/models/channel_config.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'channel_model.g.dart'; /// The class that contains the information about a channel @JsonSerializable() class ChannelModel { + /// Constructor used for json serialization + ChannelModel({ + this.id, + this.type, + this.cid, + this.config, + this.createdBy, + this.frozen, + this.lastMessageAt, + this.createdAt, + this.updatedAt, + this.deletedAt, + this.memberCount, + this.extraData, + this.team, + }); + + /// Create a new instance from a json + factory ChannelModel.fromJson(Map json) => + _$ChannelModelFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + /// The id of this channel final String id; @@ -76,40 +97,15 @@ class ChannelModel { 'team', ]; - /// Constructor used for json serialization - ChannelModel({ - this.id, - this.type, - this.cid, - this.config, - this.createdBy, - this.frozen, - this.lastMessageAt, - this.createdAt, - this.updatedAt, - this.deletedAt, - this.memberCount, - this.extraData, - this.team, - }); - /// Shortcut for channel name String get name => extraData?.containsKey('name') == true ? extraData['name'] : cid; - /// Create a new instance from a json - factory ChannelModel.fromJson(Map json) { - return _$ChannelModelFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - } - /// Serialize to json - Map toJson() { - return Serialization.moveFromExtraDataToRoot( - _$ChannelModelToJson(this), - topLevelFields, - ); - } + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$ChannelModelToJson(this), + topLevelFields, + ); /// Creates a copy of [ChannelModel] with specified attributes overridden. ChannelModel copyWith({ @@ -143,8 +139,8 @@ class ChannelModel { team: team ?? this.team, ); - /// Returns a new [ChannelModel] that is a combination of this channelModel and the given - /// [other] channelModel. + /// Returns a new [ChannelModel] that is a combination of this channelModel + /// and the given [other] channelModel. ChannelModel merge(ChannelModel other) { if (other == null) return this; return copyWith( diff --git a/packages/stream_chat/lib/src/models/channel_state.dart b/packages/stream_chat/lib/src/models/channel_state.dart index c2a3d48c5..3a1f3178d 100644 --- a/packages/stream_chat/lib/src/models/channel_state.dart +++ b/packages/stream_chat/lib/src/models/channel_state.dart @@ -1,16 +1,26 @@ import 'package:json_annotation/json_annotation.dart'; - -import '../models/read.dart'; -import '../models/user.dart'; -import 'channel_model.dart'; -import 'member.dart'; -import 'message.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/models/member.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'channel_state.g.dart'; /// The class that contains the information about a channel @JsonSerializable() class ChannelState { + /// Constructor used for json serialization + ChannelState({ + this.channel, + this.messages = const [], + this.members = const [], + this.pinnedMessages = const [], + this.watcherCount, + this.watchers = const [], + this.read = const [], + }); + /// The channel to which this state belongs final ChannelModel channel; @@ -32,17 +42,6 @@ class ChannelState { /// The list of channel reads final List read; - /// Constructor used for json serialization - ChannelState({ - this.channel, - this.messages = const [], - this.members = const [], - this.pinnedMessages = const [], - this.watcherCount, - this.watchers = const [], - this.read = const [], - }); - /// Create a new instance from a json static ChannelState fromJson(Map json) => _$ChannelStateFromJson(json); diff --git a/packages/stream_chat/lib/src/models/command.dart b/packages/stream_chat/lib/src/models/command.dart index 420f21955..a5ababd2a 100644 --- a/packages/stream_chat/lib/src/models/command.dart +++ b/packages/stream_chat/lib/src/models/command.dart @@ -5,15 +5,6 @@ part 'command.g.dart'; /// The class that contains the information about a command @JsonSerializable() class Command { - /// The name of the command - final String name; - - /// The description explaining the command - final String description; - - /// The arguments of the command - final String args; - /// Constructor used for json serialization Command({ this.name, @@ -25,6 +16,15 @@ class Command { factory Command.fromJson(Map json) => _$CommandFromJson(json); + /// The name of the command + final String name; + + /// The description explaining the command + final String description; + + /// The arguments of the command + final String args; + /// Serialize to json Map toJson() => _$CommandToJson(this); } diff --git a/packages/stream_chat/lib/src/models/device.dart b/packages/stream_chat/lib/src/models/device.dart index 972472221..150e6759c 100644 --- a/packages/stream_chat/lib/src/models/device.dart +++ b/packages/stream_chat/lib/src/models/device.dart @@ -5,12 +5,6 @@ part 'device.g.dart'; /// The class that contains the information about a device @JsonSerializable() class Device { - /// The id of the device - final String id; - - /// The notification push provider - final String pushProvider; - /// Constructor used for json serialization Device({ this.id, @@ -20,6 +14,12 @@ class Device { /// Create a new instance from a json factory Device.fromJson(Map json) => _$DeviceFromJson(json); + /// The id of the device + final String id; + + /// The notification push provider + final String pushProvider; + /// Serialize to json Map toJson() => _$DeviceToJson(this); } diff --git a/packages/stream_chat/lib/src/models/event.dart b/packages/stream_chat/lib/src/models/event.dart index 03995d679..b1140ebc7 100644 --- a/packages/stream_chat/lib/src/models/event.dart +++ b/packages/stream_chat/lib/src/models/event.dart @@ -4,17 +4,40 @@ import 'package:stream_chat/src/models/message.dart'; import 'package:stream_chat/src/models/serialization.dart'; import 'package:stream_chat/stream_chat.dart'; -import '../event_type.dart'; -import 'member.dart'; -import 'own_user.dart'; -import 'reaction.dart'; -import 'user.dart'; - part 'event.g.dart'; /// The class that contains the information about an event @JsonSerializable() class Event { + /// Constructor used for json serialization + Event({ + this.type, + this.cid, + this.connectionId, + this.createdAt, + this.me, + this.user, + this.message, + this.totalUnreadCount, + this.unreadChannels, + this.reaction, + this.online, + this.channel, + this.member, + this.channelId, + this.channelType, + this.parentId, + this.extraData, + }) : isLocal = true; + + /// Create a new instance from a json + factory Event.fromJson(Map json) => + _$EventFromJson(Serialization.moveToExtraDataFromRoot( + json, + topLevelFields, + )) + ..isLocal = false; + /// The type of the event /// [EventType] contains some predefined constant types final String type; @@ -71,27 +94,6 @@ class Event { @JsonKey(includeIfNull: false) final Map extraData; - /// Constructor used for json serialization - Event({ - this.type, - this.cid, - this.connectionId, - this.createdAt, - this.me, - this.user, - this.message, - this.totalUnreadCount, - this.unreadChannels, - this.reaction, - this.online, - this.channel, - this.member, - this.channelId, - this.channelType, - this.parentId, - this.extraData, - }) : isLocal = true; - /// Known top level fields. /// Useful for [Serialization] methods. static final topLevelFields = [ @@ -114,15 +116,6 @@ class Event { 'is_local', ]; - /// Create a new instance from a json - factory Event.fromJson(Map json) { - return _$EventFromJson(Serialization.moveToExtraDataFromRoot( - json, - topLevelFields, - )) - ..isLocal = false; - } - /// Serialize to json Map toJson() => Serialization.moveFromExtraDataToRoot( _$EventToJson(this), @@ -133,16 +126,6 @@ class Event { /// The channel embedded in the event object @JsonSerializable() class EventChannel extends ChannelModel { - /// A paginated list of channel members - final List members; - - /// Known top level fields. - /// Useful for [Serialization] methods. - static final topLevelFields = [ - 'members', - ...ChannelModel.topLevelFields, - ]; - /// Constructor used for json serialization EventChannel({ this.members, @@ -174,12 +157,21 @@ class EventChannel extends ChannelModel { ); /// Create a new instance from a json - factory EventChannel.fromJson(Map json) { - return _$EventChannelFromJson(Serialization.moveToExtraDataFromRoot( - json, - topLevelFields, - )); - } + factory EventChannel.fromJson(Map json) => + _$EventChannelFromJson(Serialization.moveToExtraDataFromRoot( + json, + topLevelFields, + )); + + /// A paginated list of channel members + final List members; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static final topLevelFields = [ + 'members', + ...ChannelModel.topLevelFields, + ]; /// Serialize to json @override diff --git a/packages/stream_chat/lib/src/models/member.dart b/packages/stream_chat/lib/src/models/member.dart index 511b7a7ba..e99d8d345 100644 --- a/packages/stream_chat/lib/src/models/member.dart +++ b/packages/stream_chat/lib/src/models/member.dart @@ -1,12 +1,35 @@ import 'package:json_annotation/json_annotation.dart'; - -import '../models/user.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'member.g.dart'; -/// The class that contains the information about the user membership in a channel +/// The class that contains the information about the user membership +/// in a channel @JsonSerializable() class Member { + /// Constructor used for json serialization + Member({ + this.user, + this.inviteAcceptedAt, + this.inviteRejectedAt, + this.invited, + this.role, + this.userId, + this.isModerator, + this.createdAt, + this.updatedAt, + this.banned, + this.shadowBanned, + }); + + /// Create a new instance from a json + factory Member.fromJson(Map json) { + final member = _$MemberFromJson(json); + return member.copyWith( + userId: member.user?.id, + ); + } + /// The interested user final User user; @@ -40,29 +63,6 @@ class Member { /// The last date of update final DateTime updatedAt; - /// Constructor used for json serialization - Member({ - this.user, - this.inviteAcceptedAt, - this.inviteRejectedAt, - this.invited, - this.role, - this.userId, - this.isModerator, - this.createdAt, - this.updatedAt, - this.banned, - this.shadowBanned, - }); - - /// Create a new instance from a json - factory Member.fromJson(Map json) { - final member = _$MemberFromJson(json); - return member.copyWith( - userId: member.user?.id, - ); - } - /// Creates a copy of [Member] with specified attributes overridden. Member copyWith({ User user, diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/models/message.dart index df1906f3d..a89324a75 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/models/message.dart @@ -1,11 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/models/attachment.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/models/user.dart'; import 'package:uuid/uuid.dart'; -import 'attachment.dart'; -import 'reaction.dart'; -import 'serialization.dart'; -import 'user.dart'; - part 'message.g.dart'; class _PinExpires { @@ -29,9 +28,11 @@ enum MessageSendingStatus { failed, /// Message failed to updated + // ignore: constant_identifier_names failed_update, /// Message failed to delete + // ignore: constant_identifier_names failed_delete, /// Message correctly sent @@ -41,7 +42,46 @@ enum MessageSendingStatus { /// The class that contains the information about a message @JsonSerializable() class Message { - /// The message ID. This is either created by Stream or set client side when the message is added. + /// Constructor used for json serialization + Message({ + String id, + this.text, + this.type, + this.attachments, + this.mentionedUsers, + this.silent, + this.shadowed, + this.reactionCounts, + this.reactionScores, + this.latestReactions, + this.ownReactions, + this.parentId, + this.quotedMessage, + this.quotedMessageId, + this.replyCount = 0, + this.threadParticipants, + this.showInChannel, + this.command, + this.createdAt, + this.updatedAt, + this.user, + this.pinned = false, + this.pinnedAt, + DateTime pinExpires, + this.pinnedBy, + this.extraData, + this.deletedAt, + this.status = MessageSendingStatus.sent, + this.skipPush, + }) : id = id ?? Uuid().v4(), + pinExpires = pinExpires?.toUtc(); + + /// Create a new instance from a json + factory Message.fromJson(Map json) => _$MessageFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + + /// The message ID. This is either created by Stream or set client side when + /// the message is added. final String id; /// The text of this message @@ -55,7 +95,8 @@ class Message { @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final String type; - /// The list of attachments, either provided by the user or generated from a command or as a result of URL scraping. + /// The list of attachments, either provided by the user or generated from a + /// command or as a result of URL scraping. @JsonKey(includeIfNull: false) final List attachments; @@ -103,6 +144,9 @@ class Message { /// If true the message is silent final bool silent; + /// If true the message will not send a push notification + final bool skipPush; + /// If true the message is shadowed @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final bool shadowed; @@ -186,45 +230,9 @@ class Message { 'pinned_at', 'pin_expires', 'pinned_by', + 'skip_push', ]; - /// Constructor used for json serialization - Message({ - String id, - this.text, - this.type, - this.attachments, - this.mentionedUsers, - this.silent, - this.shadowed, - this.reactionCounts, - this.reactionScores, - this.latestReactions, - this.ownReactions, - this.parentId, - this.quotedMessage, - this.quotedMessageId, - this.replyCount = 0, - this.threadParticipants, - this.showInChannel, - this.command, - this.createdAt, - this.updatedAt, - this.user, - this.pinned = false, - this.pinnedAt, - DateTime pinExpires, - this.pinnedBy, - this.extraData, - this.deletedAt, - this.status = MessageSendingStatus.sent, - }) : id = id ?? Uuid().v4(), - pinExpires = pinExpires?.toUtc(); - - /// Create a new instance from a json - factory Message.fromJson(Map json) => _$MessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - /// Serialize to json Map toJson() => Serialization.moveFromExtraDataToRoot( _$MessageToJson(this), topLevelFields); @@ -259,6 +267,7 @@ class Message { User pinnedBy, Map extraData, MessageSendingStatus status, + bool skipPush, }) { assert(() { if (pinExpires is! DateTime && @@ -267,7 +276,7 @@ class Message { throw ArgumentError('`pinExpires` can only be set as DateTime or null'); } return true; - }()); + }(), 'Validate type for pinExpires'); return Message( id: id ?? this.id, text: text ?? this.text, @@ -297,11 +306,12 @@ class Message { pinnedAt: pinnedAt ?? this.pinnedAt, pinnedBy: pinnedBy ?? this.pinnedBy, pinExpires: pinExpires == _pinExpires ? this.pinExpires : pinExpires, + skipPush: skipPush ?? this.skipPush, ); } - /// Returns a new [Message] that is a combination of this message and the given - /// [other] message. + /// Returns a new [Message] that is a combination of this message and the + /// given [other] message. Message merge(Message other) { if (other == null) return this; return copyWith( @@ -344,6 +354,12 @@ class TranslatedMessage extends Message { /// Constructor used for json serialization TranslatedMessage(this.i18n); + /// Create a new instance from a json + factory TranslatedMessage.fromJson(Map json) => + _$TranslatedMessageFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields), + ); + /// A Map of final Map i18n; @@ -354,13 +370,6 @@ class TranslatedMessage extends Message { ...Message.topLevelFields, ]; - /// Create a new instance from a json - factory TranslatedMessage.fromJson(Map json) { - return _$TranslatedMessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields), - ); - } - /// Serialize to json @override Map toJson() => Serialization.moveFromExtraDataToRoot( diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/models/message.g.dart index f8ac0c3d8..80df15893 100644 --- a/packages/stream_chat/lib/src/models/message.g.dart +++ b/packages/stream_chat/lib/src/models/message.g.dart @@ -93,6 +93,7 @@ Message _$MessageFromJson(Map json) { deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + skipPush: json['skip_push'] as bool, ); } @@ -123,6 +124,7 @@ Map _$MessageToJson(Message instance) { writeNotNull('thread_participants', readonly(instance.threadParticipants)); val['show_in_channel'] = instance.showInChannel; val['silent'] = instance.silent; + val['skip_push'] = instance.skipPush; writeNotNull('shadowed', readonly(instance.shadowed)); writeNotNull('command', readonly(instance.command)); writeNotNull('created_at', readonly(instance.createdAt)); diff --git a/packages/stream_chat/lib/src/models/mute.dart b/packages/stream_chat/lib/src/models/mute.dart index f33a99569..e3d5e1a03 100644 --- a/packages/stream_chat/lib/src/models/mute.dart +++ b/packages/stream_chat/lib/src/models/mute.dart @@ -1,14 +1,19 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/models/channel_model.dart'; - -import 'serialization.dart'; -import 'user.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'mute.g.dart'; /// The class that contains the information about a muted user @JsonSerializable() class Mute { + /// Constructor used for json serialization + Mute({this.user, this.channel, this.createdAt, this.updatedAt}); + + /// Create a new instance from a json + factory Mute.fromJson(Map json) => _$MuteFromJson(json); + /// The user that performed the muting action @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final User user; @@ -25,12 +30,6 @@ class Mute { @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final DateTime updatedAt; - /// Constructor used for json serialization - Mute({this.user, this.channel, this.createdAt, this.updatedAt}); - - /// Create a new instance from a json - factory Mute.fromJson(Map json) => _$MuteFromJson(json); - /// Serialize to json Map toJson() => _$MuteToJson(this); } diff --git a/packages/stream_chat/lib/src/models/own_user.dart b/packages/stream_chat/lib/src/models/own_user.dart index 3246788c3..18ee2abf6 100644 --- a/packages/stream_chat/lib/src/models/own_user.dart +++ b/packages/stream_chat/lib/src/models/own_user.dart @@ -1,9 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'device.dart'; -import 'mute.dart'; -import 'serialization.dart'; -import 'user.dart'; +import 'package:stream_chat/src/models/device.dart'; +import 'package:stream_chat/src/models/mute.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'own_user.g.dart'; @@ -11,6 +10,36 @@ part 'own_user.g.dart'; /// This object can be found in [Event] @JsonSerializable() class OwnUser extends User { + /// Constructor used for json serialization + OwnUser({ + this.devices, + this.mutes, + this.totalUnreadCount, + this.unreadChannels, + this.channelMutes, + String id, + String role, + DateTime createdAt, + DateTime updatedAt, + DateTime lastActive, + bool online, + Map extraData, + bool banned, + }) : super( + id: id, + role: role, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + extraData: extraData, + banned: banned, + ); + + /// Create a new instance from a json + factory OwnUser.fromJson(Map json) => _$OwnUserFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + /// List of user devices @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final List devices; @@ -42,42 +71,8 @@ class OwnUser extends User { ...User.topLevelFields, ]; - /// Constructor used for json serialization - OwnUser({ - this.devices, - this.mutes, - this.totalUnreadCount, - this.unreadChannels, - this.channelMutes, - String id, - String role, - DateTime createdAt, - DateTime updatedAt, - DateTime lastActive, - bool online, - Map extraData, - bool banned, - }) : super( - id: id, - role: role, - createdAt: createdAt, - updatedAt: updatedAt, - lastActive: lastActive, - online: online, - extraData: extraData, - banned: banned, - ); - - /// Create a new instance from a json - factory OwnUser.fromJson(Map json) { - return _$OwnUserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - } - /// Serialize to json @override - Map toJson() { - return Serialization.moveFromExtraDataToRoot( - _$OwnUserToJson(this), topLevelFields); - } + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$OwnUserToJson(this), topLevelFields); } diff --git a/packages/stream_chat/lib/src/models/reaction.dart b/packages/stream_chat/lib/src/models/reaction.dart index f09577619..6792bf3f0 100644 --- a/packages/stream_chat/lib/src/models/reaction.dart +++ b/packages/stream_chat/lib/src/models/reaction.dart @@ -1,13 +1,27 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'serialization.dart'; -import 'user.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'reaction.g.dart'; /// The class that defines a reaction @JsonSerializable() class Reaction { + /// Constructor used for json serialization + Reaction({ + this.messageId, + this.createdAt, + this.type, + this.user, + String userId, + this.score, + this.extraData, + }) : userId = userId ?? user?.id; + + /// Create a new instance from a json + factory Reaction.fromJson(Map json) => _$ReactionFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + /// The messageId to which the reaction belongs final String messageId; @@ -43,28 +57,9 @@ class Reaction { 'score', ]; - /// Constructor used for json serialization - Reaction({ - this.messageId, - this.createdAt, - this.type, - this.user, - String userId, - this.score, - this.extraData, - }) : userId = userId ?? user?.id; - - /// Create a new instance from a json - factory Reaction.fromJson(Map json) { - return _$ReactionFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - } - /// Serialize to json - Map toJson() { - return Serialization.moveFromExtraDataToRoot( - _$ReactionToJson(this), topLevelFields); - } + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$ReactionToJson(this), topLevelFields); /// Creates a copy of [Reaction] with specified attributes overridden. Reaction copyWith({ @@ -75,20 +70,19 @@ class Reaction { String userId, int score, Map extraData, - }) { - return Reaction( - messageId: messageId ?? this.messageId, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - user: user ?? this.user, - userId: userId ?? this.userId, - score: score ?? this.score, - extraData: extraData ?? this.extraData, - ); - } + }) => + Reaction( + messageId: messageId ?? this.messageId, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + user: user ?? this.user, + userId: userId ?? this.userId, + score: score ?? this.score, + extraData: extraData ?? this.extraData, + ); - /// Returns a new [Reaction] that is a combination of this reaction and the given - /// [other] reaction. + /// Returns a new [Reaction] that is a combination of this reaction and the + /// given [other] reaction. Reaction merge(Reaction other) { if (other == null) return this; return copyWith( diff --git a/packages/stream_chat/lib/src/models/read.dart b/packages/stream_chat/lib/src/models/read.dart index ae85293d5..2be639d53 100644 --- a/packages/stream_chat/lib/src/models/read.dart +++ b/packages/stream_chat/lib/src/models/read.dart @@ -1,21 +1,11 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'user.dart'; +import 'package:stream_chat/src/models/user.dart'; part 'read.g.dart'; /// The class that defines a read event @JsonSerializable() class Read { - /// Date of the read event - final DateTime lastRead; - - /// User who sent the event - final User user; - - /// Number of unread messages - final int unreadMessages; - /// Constructor used for json serialization Read({ this.lastRead, @@ -26,6 +16,15 @@ class Read { /// Create a new instance from a json factory Read.fromJson(Map json) => _$ReadFromJson(json); + /// Date of the read event + final DateTime lastRead; + + /// User who sent the event + final User user; + + /// Number of unread messages + final int unreadMessages; + /// Serialize to json Map toJson() => _$ReadToJson(this); } diff --git a/packages/stream_chat/lib/src/models/serialization.dart b/packages/stream_chat/lib/src/models/serialization.dart index 239ccb50f..18cdd545d 100644 --- a/packages/stream_chat/lib/src/models/serialization.dart +++ b/packages/stream_chat/lib/src/models/serialization.dart @@ -1,6 +1,7 @@ -import 'user.dart'; +import 'package:stream_chat/src/models/user.dart'; /// Used to avoid to serialize properties to json +// ignore: prefer_void_to_null Null readonly(_) => null; /// Helper class for serialization to and from json @@ -9,9 +10,8 @@ class Serialization { static const Function readOnly = readonly; /// List of users to list of userIds - static List userIds(List users) { - return users?.map((u) => u.id)?.toList(); - } + static List userIds(List users) => + users?.map((u) => u.id)?.toList(); /// Takes unknown json keys and puts them in the `extra_data` key static Map moveToExtraDataFromRoot( @@ -34,7 +34,8 @@ class Serialization { }); } - /// Takes values in `extra_data` key and puts them on the root level of the json map + /// Takes values in `extra_data` key and puts them on the root level of + /// the json map static Map moveFromExtraDataToRoot( Map json, List topLevelFields, diff --git a/packages/stream_chat/lib/src/models/user.dart b/packages/stream_chat/lib/src/models/user.dart index 00ed12002..618c1986e 100644 --- a/packages/stream_chat/lib/src/models/user.dart +++ b/packages/stream_chat/lib/src/models/user.dart @@ -1,12 +1,53 @@ import 'package:json_annotation/json_annotation.dart'; - -import 'serialization.dart'; +import 'package:stream_chat/src/models/serialization.dart'; part 'user.g.dart'; /// The class that defines the user model @JsonSerializable() class User { + /// Constructor used for json serialization + User({ + this.id, + this.role, + this.createdAt, + this.updatedAt, + this.lastActive, + this.online, + this.extraData, + this.banned, + this.teams, + }); + + /// Create a new instance from a json + factory User.fromJson(Map json) => _$UserFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Use this named constructor to create a new user instance + User.init( + this.id, { + this.online, + this.extraData, + }) : createdAt = null, + updatedAt = null, + lastActive = null, + banned = null, + teams = null, + role = null; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static const topLevelFields = [ + 'id', + 'role', + 'created_at', + 'updated_at', + 'last_active', + 'online', + 'banned', + 'teams', + ]; + /// User id final String id; @@ -42,43 +83,8 @@ class User { @JsonKey(includeIfNull: false) final Map extraData; - /// Known top level fields. - /// Useful for [Serialization] methods. - static const topLevelFields = [ - 'id', - 'role', - 'created_at', - 'updated_at', - 'last_active', - 'online', - 'banned', - 'teams', - ]; - - /// Use this named constructor to create a new user instance - User.init( - this.id, { - this.online, - this.extraData, - }) : createdAt = null, - updatedAt = null, - lastActive = null, - banned = null, - teams = null, - role = null; - - /// Constructor used for json serialization - User({ - this.id, - this.role, - this.createdAt, - this.updatedAt, - this.lastActive, - this.online, - this.extraData, - this.banned, - this.teams, - }); + @override + int get hashCode => id.hashCode; /// Shortcut for user name String get name => @@ -86,23 +92,12 @@ class User { ? extraData['name'] : id; - /// Create a new instance from a json - factory User.fromJson(Map json) { - return _$UserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - } - - /// Serialize to json - Map toJson() { - return Serialization.moveFromExtraDataToRoot( - _$UserToJson(this), topLevelFields); - } - @override bool operator ==(Object other) => identical(this, other) || other is User && runtimeType == other.runtimeType && id == other.id; - @override - int get hashCode => id.hashCode; + /// Serialize to json + Map toJson() => + Serialization.moveFromExtraDataToRoot(_$UserToJson(this), topLevelFields); } diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/platform_detector/platform_detector.dart index 32748256b..58d86db41 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/platform_detector/platform_detector.dart @@ -1,29 +1,29 @@ -import 'platform_detector_stub.dart' +import 'package:stream_chat/src/platform_detector/platform_detector_stub.dart' if (dart.library.html) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; /// Possible platforms enum PlatformType { /// - Android, + android, /// - Ios, + ios, /// - Web, + web, /// - MacOS, + macOS, /// - Windows, + windows, /// - Linux, + linux, /// - Fuchsia, + fuchsia, } /// Utility class that provides information on the current platform @@ -31,42 +31,42 @@ class CurrentPlatform { CurrentPlatform._(); /// True if the app is running on android - static bool get isAndroid => type == PlatformType.Android; + static bool get isAndroid => type == PlatformType.android; /// True if the app is running on ios - static bool get isIos => type == PlatformType.Ios; + static bool get isIos => type == PlatformType.ios; /// True if the app is running on web - static bool get isWeb => type == PlatformType.Web; + static bool get isWeb => type == PlatformType.web; /// True if the app is running on macos - static bool get isMacOS => type == PlatformType.MacOS; + static bool get isMacOS => type == PlatformType.macOS; /// True if the app is running on windows - static bool get isWindows => type == PlatformType.Windows; + static bool get isWindows => type == PlatformType.windows; /// True if the app is running on linux - static bool get isLinux => type == PlatformType.Linux; + static bool get isLinux => type == PlatformType.linux; /// True if the app is running on fuchsia - static bool get isFuchsia => type == PlatformType.Fuchsia; + static bool get isFuchsia => type == PlatformType.fuchsia; /// Returns a string version of the platform static String get name { switch (type) { - case PlatformType.Android: + case PlatformType.android: return 'android'; - case PlatformType.Ios: + case PlatformType.ios: return 'ios'; - case PlatformType.Web: + case PlatformType.web: return 'web'; - case PlatformType.MacOS: + case PlatformType.macOS: return 'macos'; - case PlatformType.Windows: + case PlatformType.windows: return 'windows'; - case PlatformType.Linux: + case PlatformType.linux: return 'linux'; - case PlatformType.Fuchsia: + case PlatformType.fuchsia: return 'fuchsia'; default: return ''; diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart b/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart index df74f5d88..c7b4a0b7f 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart +++ b/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart @@ -1,12 +1,12 @@ import 'dart:io'; -import 'platform_detector.dart'; +import 'package:stream_chat/src/platform_detector/platform_detector.dart'; /// Version running on native systems PlatformType get currentPlatform { - if (Platform.isWindows) return PlatformType.Windows; - if (Platform.isFuchsia) return PlatformType.Fuchsia; - if (Platform.isMacOS) return PlatformType.MacOS; - if (Platform.isLinux) return PlatformType.Linux; - if (Platform.isIOS) return PlatformType.Ios; - return PlatformType.Android; + if (Platform.isWindows) return PlatformType.windows; + if (Platform.isFuchsia) return PlatformType.fuchsia; + if (Platform.isMacOS) return PlatformType.macOS; + if (Platform.isLinux) return PlatformType.linux; + if (Platform.isIOS) return PlatformType.ios; + return PlatformType.android; } diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart b/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart index f9143deb1..b9e13c2e7 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart +++ b/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart @@ -1,4 +1,4 @@ -import 'platform_detector.dart'; +import 'package:stream_chat/src/platform_detector/platform_detector.dart'; /// Stub implementation PlatformType get currentPlatform { diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart b/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart index 4274bf719..ba5d04fc6 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart +++ b/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart @@ -1,4 +1,4 @@ -import 'platform_detector.dart'; +import 'package:stream_chat/src/platform_detector/platform_detector.dart'; /// Version running on web -PlatformType get currentPlatform => PlatformType.Web; +PlatformType get currentPlatform => PlatformType.web; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index f0b3e60d4..0e1c9ed2c 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -1,5 +1,6 @@ library stream_chat; +export 'package:async/async.dart'; export 'package:dio/src/dio_error.dart'; export 'package:dio/src/multipart_file.dart'; export 'package:dio/src/options.dart' show ProgressCallback; @@ -12,7 +13,10 @@ export './src/api/requests.dart'; export './src/api/responses.dart'; export './src/attachment_file_uploader.dart' show AttachmentFileUploader; export './src/client.dart'; +export './src/db/chat_persistence_client.dart'; export './src/event_type.dart'; +export './src/extensions/rate_limit.dart'; +export './src/extensions/string_extension.dart'; export './src/models/action.dart'; export './src/models/attachment.dart'; export './src/models/attachment_file.dart'; @@ -29,5 +33,3 @@ export './src/models/own_user.dart'; export './src/models/reaction.dart'; export './src/models/read.dart'; export './src/models/user.dart'; -export './src/extensions/string_extension.dart'; -export './src/db/chat_persistence_client.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 27294084e..207ba25ef 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -2,4 +2,5 @@ import 'package:stream_chat/src/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header -const PACKAGE_VERSION = '1.3.2+1-beta'; +// ignore: constant_identifier_names +const PACKAGE_VERSION = '1.4.0-beta'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 927a997e8..410cc5612 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.3.2+1-beta +version: 1.4.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -9,23 +9,22 @@ environment: sdk: ">=2.7.0 <3.0.0" dependencies: - json_annotation: ^3.0.1 - logging: ^0.11.4 - dio: ^3.0.10 - web_socket_channel: ^1.1.0 - uuid: ^2.2.2 async: ^2.4.2 - rxdart: ^0.25.0 collection: ^1.14.13 - pedantic: ^1.9.2 - meta: ^1.2.4 - mime: ^0.9.7 + dio: ^3.0.10 freezed_annotation: ^0.12.0 http_parser: ^3.1.4 + json_annotation: ^3.0.1 + logging: ^0.11.4 + meta: ^1.2.4 + mime: ^0.9.7 + rxdart: ^0.25.0 + uuid: ^2.2.2 + web_socket_channel: ^1.1.0 dev_dependencies: build_runner: ^1.10.0 + freezed: ^0.12.7 json_serializable: ^3.3.0 - test: ^1.15.7 mockito: ^4.1.1 - freezed: ^0.12.7 + test: ^1.15.7 diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart index e70724a3a..21b9d09ba 100644 --- a/packages/stream_chat/test/src/client_test.dart +++ b/packages/stream_chat/test/src/client_test.dart @@ -188,7 +188,7 @@ void main() { } }; - final query = 'hello'; + const query = 'hello'; final queryParams = { 'payload': json.encode({ diff --git a/packages/stream_chat/test/src/models/channel_state_test.dart b/packages/stream_chat/test/src/models/channel_state_test.dart index 538f18122..0a14cb76b 100644 --- a/packages/stream_chat/test/src/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/models/channel_state_test.dart @@ -897,6 +897,7 @@ void main() { "show_in_channel": null, "mentioned_users": [], "status": "SENT", + "skip_push": null, "silent": false, "pinned": false, "pinned_at": null, @@ -913,6 +914,7 @@ void main() { "show_in_channel": null, "mentioned_users": [], "status": "SENT", + "skip_push": null, "silent": false, "pinned": false, "pinned_at": null, @@ -922,6 +924,7 @@ void main() { { "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", "text": "test message", + "skip_push": null, "attachments": [], "parent_id": null, "quoted_message": null, @@ -944,6 +947,7 @@ void main() { "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], + "skip_push": null, "status": "SENT", "silent": false, "pinned": false, @@ -955,6 +959,7 @@ void main() { "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", "text": "test", "attachments": [], + "skip_push": null, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -972,6 +977,7 @@ void main() { "text": "hi", "attachments": [], "parent_id": null, + "skip_push": null, "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, @@ -989,6 +995,7 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, + "skip_push": null, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1011,6 +1018,7 @@ void main() { "status": "SENT", "silent": false, "pinned": false, + "skip_push": null, "pinned_at": null, "pin_expires": null, "pinned_by": null @@ -1028,6 +1036,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1039,6 +1048,7 @@ void main() { "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, + "skip_push": null, "mentioned_users": [], "status": "SENT", "silent": false, @@ -1056,6 +1066,7 @@ void main() { "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], + "skip_push": null, "status": "SENT", "silent": false, "pinned": false, @@ -1074,6 +1085,7 @@ void main() { "mentioned_users": [], "status": "SENT", "silent": false, + "skip_push": null, "pinned": false, "pinned_at": null, "pin_expires": null, @@ -1083,6 +1095,7 @@ void main() { "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", "text": "what are you doing?", "attachments": [], + "skip_push": null, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1100,6 +1113,7 @@ void main() { "text": "👍", "attachments": [], "parent_id": null, + "skip_push": null, "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, @@ -1115,6 +1129,7 @@ void main() { "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", "text": "sdasas", "attachments": [], + "skip_push": null, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1135,6 +1150,7 @@ void main() { "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, + "skip_push": null, "mentioned_users": [], "status": "SENT", "silent": false, @@ -1147,6 +1163,7 @@ void main() { "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", "text": "nhisagdhsadz", "attachments": [], + "skip_push": null, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1165,6 +1182,7 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, + "skip_push": null, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1181,6 +1199,7 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, + "skip_push": null, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1188,6 +1207,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1204,6 +1224,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1220,6 +1241,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1236,6 +1258,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1252,6 +1275,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1268,6 +1292,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null }, @@ -1284,6 +1309,7 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, + "skip_push": null, "pin_expires": null, "pinned_by": null } diff --git a/packages/stream_chat/test/src/models/message_test.dart b/packages/stream_chat/test/src/models/message_test.dart index fe8fc44ae..3541b2c02 100644 --- a/packages/stream_chat/test/src/models/message_test.dart +++ b/packages/stream_chat/test/src/models/message_test.dart @@ -134,6 +134,7 @@ void main() { "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", "silent": false, + "skip_push": null, "attachments": [ { "type": "video", diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 73ac6d4bd..d432f4418 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,17 @@ +## 1.4.0-beta + +- Unfocus `MessageInput` only when sending commands +- Updated default error for `MessageSearchListView` +- Show error messages as system and keep them in the message input +- Remove notification badge logic +- Use shimmer while loading images +- Polished `StreamChatTheme` adding more options and a new `MessageInputTheme` dedicated to `MessageInput` +- Add possibility to specify custom message actions using `MessageWidget.customActions` +- Added `MessageListView.onAttachmentTap` callback +- Fixed message newline issue +- Fixed `MessageListView` scroll keyboard behaviour +- Minor fixes and improveqments + ## 1.3.2-beta - Updated `stream_chat_core` dependency diff --git a/packages/stream_chat_flutter/example/.fvm/flutter_sdk b/packages/stream_chat_flutter/example/.fvm/flutter_sdk new file mode 120000 index 000000000..cdf17889e --- /dev/null +++ b/packages/stream_chat_flutter/example/.fvm/flutter_sdk @@ -0,0 +1 @@ +/Users/salvatoregiordano/fvm/versions/beta \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/.fvm/fvm_config.json b/packages/stream_chat_flutter/example/.fvm/fvm_config.json new file mode 100644 index 000000000..6504dcd01 --- /dev/null +++ b/packages/stream_chat_flutter/example/.fvm/fvm_config.json @@ -0,0 +1,3 @@ +{ + "flutterSdkVersion": "beta" +} \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle index 5a4d759a4..9bb36eac5 100644 --- a/packages/stream_chat_flutter/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -34,6 +34,7 @@ android { lintOptions { disable 'InvalidPackage' + checkReleaseBuilds false } defaultConfig { diff --git a/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml index 3197993b5..42c5fa1ea 100644 --- a/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ FlutterApplication and put your custom class here. --> + + + + + + + diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/values-night/styles.xml b/packages/stream_chat_flutter/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..449a9f930 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/example/android/build.gradle b/packages/stream_chat_flutter/example/android/build.gradle index dc5cdbc9d..9afae7f84 100644 --- a/packages/stream_chat_flutter/example/android/build.gradle +++ b/packages/stream_chat_flutter/example/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.0.1' + classpath 'com.android.tools.build:gradle:3.6.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16e..919434a62 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart index 59dde1ee0..110cc1dc6 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart @@ -44,7 +44,12 @@ class MyApp extends StatelessWidget { final defaultTheme = StreamChatThemeData.fromTheme(themeData); final colorTheme = defaultTheme.colorTheme; final customTheme = defaultTheme.merge(StreamChatThemeData( - ownMessageTheme: MessageTheme( + channelPreviewTheme: ChannelPreviewTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(8), + ), + ), + otherMessageTheme: MessageTheme( messageBackgroundColor: colorTheme.black, messageText: TextStyle( color: colorTheme.white, diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 4d32fb58c..0c7ac24d2 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -25,7 +25,11 @@ dependencies: sdk: flutter stream_chat_flutter: path: ../ - stream_chat_persistence: ^1.3.0-beta + stream_chat_persistence: + git: + url: https://github.com/GetStream/stream-chat-flutter.git + ref: develop + path: packages/stream_chat_persistence # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/packages/stream_chat_flutter/example/web/favicon.png b/packages/stream_chat_flutter/example/web/favicon.png new file mode 100644 index 000000000..8aaa46ac1 Binary files /dev/null and b/packages/stream_chat_flutter/example/web/favicon.png differ diff --git a/packages/stream_chat_flutter/example/web/icons/Icon-192.png b/packages/stream_chat_flutter/example/web/icons/Icon-192.png new file mode 100644 index 000000000..b749bfef0 Binary files /dev/null and b/packages/stream_chat_flutter/example/web/icons/Icon-192.png differ diff --git a/packages/stream_chat_flutter/example/web/icons/Icon-512.png b/packages/stream_chat_flutter/example/web/icons/Icon-512.png new file mode 100644 index 000000000..88cfd48df Binary files /dev/null and b/packages/stream_chat_flutter/example/web/icons/Icon-512.png differ diff --git a/packages/stream_chat_flutter/example/web/index.html b/packages/stream_chat_flutter/example/web/index.html new file mode 100644 index 000000000..fb0535656 --- /dev/null +++ b/packages/stream_chat_flutter/example/web/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + example + + + + + + + + diff --git a/packages/stream_chat_flutter/example/web/manifest.json b/packages/stream_chat_flutter/example/web/manifest.json new file mode 100644 index 000000000..8c012917d --- /dev/null +++ b/packages/stream_chat_flutter/example/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/packages/stream_chat_flutter/example/web/sql-wasm.js b/packages/stream_chat_flutter/example/web/sql-wasm.js new file mode 100644 index 000000000..f04a231d6 --- /dev/null +++ b/packages/stream_chat_flutter/example/web/sql-wasm.js @@ -0,0 +1,203 @@ + +// We are modularizing this manually because the current modularize setting in Emscripten has some issues: +// https://github.com/kripken/emscripten/issues/5820 +// In addition, When you use emcc's modularization, it still expects to export a global object called `Module`, +// which is able to be used/called before the WASM is loaded. +// The modularization below exports a promise that loads and resolves to the actual sql.js module. +// That way, this module can't be used before the WASM is finished loading. + +// We are going to define a function that a user will call to start loading initializing our Sql.js library +// However, that function might be called multiple times, and on subsequent calls, we don't actually want it to instantiate a new instance of the Module +// Instead, we want to return the previously loaded module + +// TODO: Make this not declare a global if used in the browser +var initSqlJsPromise = undefined; + +var initSqlJs = function (moduleConfig) { + + if (initSqlJsPromise){ + return initSqlJsPromise; + } + // If we're here, we've never called this function before + initSqlJsPromise = new Promise(function (resolveModule, reject) { + + // We are modularizing this manually because the current modularize setting in Emscripten has some issues: + // https://github.com/kripken/emscripten/issues/5820 + + // The way to affect the loading of emcc compiled modules is to create a variable called `Module` and add + // properties to it, like `preRun`, `postRun`, etc + // We are using that to get notified when the WASM has finished loading. + // Only then will we return our promise + + // If they passed in a moduleConfig object, use that + // Otherwise, initialize Module to the empty object + var Module = typeof moduleConfig !== 'undefined' ? moduleConfig : {}; + + // EMCC only allows for a single onAbort function (not an array of functions) + // So if the user defined their own onAbort function, we remember it and call it + var originalOnAbortFunction = Module['onAbort']; + Module['onAbort'] = function (errorThatCausedAbort) { + reject(new Error(errorThatCausedAbort)); + if (originalOnAbortFunction){ + originalOnAbortFunction(errorThatCausedAbort); + } + }; + + Module['postRun'] = Module['postRun'] || []; + Module['postRun'].push(function () { + // When Emscripted calls postRun, this promise resolves with the built Module + resolveModule(Module); + }); + + // There is a section of code in the emcc-generated code below that looks like this: + // (Note that this is lowercase `module`) + // if (typeof module !== 'undefined') { + // module['exports'] = Module; + // } + // When that runs, it's going to overwrite our own modularization export efforts in shell-post.js! + // The only way to tell emcc not to emit it is to pass the MODULARIZE=1 or MODULARIZE_INSTANCE=1 flags, + // but that carries with it additional unnecessary baggage/bugs we don't want either. + // So, we have three options: + // 1) We undefine `module` + // 2) We remember what `module['exports']` was at the beginning of this function and we restore it later + // 3) We write a script to remove those lines of code as part of the Make process. + // + // Since those are the only lines of code that care about module, we will undefine it. It's the most straightforward + // of the options, and has the side effect of reducing emcc's efforts to modify the module if its output were to change in the future. + // That's a nice side effect since we're handling the modularization efforts ourselves + module = undefined; + + // The emcc-generated code and shell-post.js code goes below, + // meaning that all of it runs inside of this promise. If anything throws an exception, our promise will abort + +var e;e||(e=typeof Module !== 'undefined' ? Module : {});null; +e.onRuntimeInitialized=function(){function a(h,l){this.Ra=h;this.db=l;this.Qa=1;this.lb=[]}function b(h,l){this.db=l;l=aa(h)+1;this.eb=ba(l);if(null===this.eb)throw Error("Unable to allocate memory for the SQL string");k(h,m,this.eb,l);this.jb=this.eb;this.$a=this.pb=null}function c(h){this.filename="dbfile_"+(4294967295*Math.random()>>>0);if(null!=h){var l=this.filename,p=l?r("//"+l):"/";l=ca(!0,!0);p=da(p,(void 0!==l?l:438)&4095|32768,0);if(h){if("string"===typeof h){for(var q=Array(h.length),B= +0,ha=h.length;Bd;++d)g.parameters.push(f["viii"[d]]); +d=new WebAssembly.Function(g,a)}else{f=[1,0,1,96];g={i:127,j:126,f:125,d:124};f.push(3);for(d=0;3>d;++d)f.push(g["iii"[d]]);f.push(0);f[1]=f.length-2;d=new Uint8Array([0,97,115,109,1,0,0,0].concat(f,[2,7,1,1,101,1,102,0,0,7,5,1,1,102,0,0]));d=new WebAssembly.Module(d);d=(new WebAssembly.Instance(d,{e:{f:a}})).exports.f}b.set(c,d)}Ia.set(a,c);a=c}return a}function ra(a){ua(a)}var Ka;e.wasmBinary&&(Ka=e.wasmBinary);var noExitRuntime;e.noExitRuntime&&(noExitRuntime=e.noExitRuntime); +"object"!==typeof WebAssembly&&K("no native wasm support detected"); +function pa(a){var b="i32";"*"===b.charAt(b.length-1)&&(b="i32");switch(b){case "i1":z[a>>0]=0;break;case "i8":z[a>>0]=0;break;case "i16":La[a>>1]=0;break;case "i32":L[a>>2]=0;break;case "i64":M=[0,(N=0,1<=+Math.abs(N)?0>>0:~~+Math.ceil((N-+(~~N>>>0))/4294967296)>>>0:0)];L[a>>2]=M[0];L[a+4>>2]=M[1];break;case "float":Ma[a>>2]=0;break;case "double":Na[a>>3]=0;break;default:K("invalid type for setValue: "+b)}} +function x(a,b){b=b||"i8";"*"===b.charAt(b.length-1)&&(b="i32");switch(b){case "i1":return z[a>>0];case "i8":return z[a>>0];case "i16":return La[a>>1];case "i32":return L[a>>2];case "i64":return L[a>>2];case "float":return Ma[a>>2];case "double":return Na[a>>3];default:K("invalid type for getValue: "+b)}return null}var Oa,Ja,Pa=!1;function assert(a,b){a||K("Assertion failed: "+b)}function Qa(a){var b=e["_"+a];assert(b,"Cannot call unknown function "+a+", make sure it is exported");return b} +function Ra(a,b,c,d){var f={string:function(v){var C=0;if(null!==v&&void 0!==v&&0!==v){var H=(v.length<<2)+1;C=y(H);k(v,m,C,H)}return C},array:function(v){var C=y(v.length);z.set(v,C);return C}},g=Qa(a),n=[];a=0;if(d)for(var t=0;t=d);)++c;if(16f?d+=String.fromCharCode(f):(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|f&1023))}}else d+=String.fromCharCode(f)}return d}function A(a,b){return a?Va(m,a,b):""} +function k(a,b,c,d){if(!(0=n){var t=a.charCodeAt(++g);n=65536+((n&1023)<<10)|t&1023}if(127>=n){if(c>=d)break;b[c++]=n}else{if(2047>=n){if(c+1>=d)break;b[c++]=192|n>>6}else{if(65535>=n){if(c+2>=d)break;b[c++]=224|n>>12}else{if(c+3>=d)break;b[c++]=240|n>>18;b[c++]=128|n>>12&63}b[c++]=128|n>>6&63}b[c++]=128|n&63}}b[c]=0;return c-f} +function aa(a){for(var b=0,c=0;c=d&&(d=65536+((d&1023)<<10)|a.charCodeAt(++c)&1023);127>=d?++b:b=2047>=d?b+2:65535>=d?b+3:b+4}return b}function Wa(a){var b=aa(a)+1,c=ba(b);c&&k(a,z,c,b);return c}var Xa,z,m,La,L,Ma,Na; +function Ya(a){Xa=a;e.HEAP8=z=new Int8Array(a);e.HEAP16=La=new Int16Array(a);e.HEAP32=L=new Int32Array(a);e.HEAPU8=m=new Uint8Array(a);e.HEAPU16=new Uint16Array(a);e.HEAPU32=new Uint32Array(a);e.HEAPF32=Ma=new Float32Array(a);e.HEAPF64=Na=new Float64Array(a)}var Za=e.INITIAL_MEMORY||16777216;e.wasmMemory?Oa=e.wasmMemory:Oa=new WebAssembly.Memory({initial:Za/65536,maximum:32768});Oa&&(Xa=Oa.buffer);Za=Xa.byteLength;Ya(Xa);var $a=[],ab=[],bb=[],cb=[]; +function db(){var a=e.preRun.shift();$a.unshift(a)}var eb=0,fb=null,gb=null;e.preloadedImages={};e.preloadedAudios={};function K(a){if(e.onAbort)e.onAbort(a);J(a);Pa=!0;throw new WebAssembly.RuntimeError("abort("+a+"). Build with -s ASSERTIONS=1 for more info.");}function hb(a){var b=ib;return String.prototype.startsWith?b.startsWith(a):0===b.indexOf(a)}function jb(){return hb("data:application/octet-stream;base64,")}var ib="sql-wasm.wasm"; +if(!jb()){var kb=ib;ib=e.locateFile?e.locateFile(kb,I):I+kb}function lb(){try{if(Ka)return new Uint8Array(Ka);if(Ca)return Ca(ib);throw"both async and sync fetching of the wasm failed";}catch(a){K(a)}}function mb(){return Ka||!ya&&!G||"function"!==typeof fetch||hb("file://")?Promise.resolve().then(lb):fetch(ib,{credentials:"same-origin"}).then(function(a){if(!a.ok)throw"failed to load wasm binary file at '"+ib+"'";return a.arrayBuffer()}).catch(function(){return lb()})}var N,M; +function nb(a){for(;0>2]=60*(new Date).getTimezoneOffset();var b=(new Date).getFullYear(),c=new Date(b,0,1);b=new Date(b,6,1);L[vb()>>2]=Number(c.getTimezoneOffset()!=b.getTimezoneOffset());var d=a(c),f=a(b);d=Wa(d);f=Wa(f);b.getTimezoneOffset()>2]=d,L[xb()+4>>2]=f):(L[xb()>>2]=f,L[xb()+4>>2]=d)}}var tb; +function yb(a,b){for(var c=0,d=a.length-1;0<=d;d--){var f=a[d];"."===f?a.splice(d,1):".."===f?(a.splice(d,1),c++):c&&(a.splice(d,1),c--)}if(b)for(;c;c--)a.unshift("..");return a}function r(a){var b="/"===a.charAt(0),c="/"===a.substr(-1);(a=yb(a.split("/").filter(function(d){return!!d}),!b).join("/"))||b||(a=".");a&&c&&(a+="/");return(b?"/":"")+a} +function zb(a){var b=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/.exec(a).slice(1);a=b[0];b=b[1];if(!a&&!b)return".";b&&(b=b.substr(0,b.length-1));return a+b}function Ab(a){if("/"===a)return"/";a=r(a);a=a.replace(/\/$/,"");var b=a.lastIndexOf("/");return-1===b?a:a.substr(b+1)}function Bb(a){L[Cb()>>2]=a} +function Db(){if("object"===typeof crypto&&"function"===typeof crypto.getRandomValues){var a=new Uint8Array(1);return function(){crypto.getRandomValues(a);return a[0]}}if(za)try{var b=require("crypto");return function(){return b.randomBytes(1)[0]}}catch(c){}return function(){K("randomDevice")}} +function Eb(){for(var a="",b=!1,c=arguments.length-1;-1<=c&&!b;c--){b=0<=c?arguments[c]:"/";if("string"!==typeof b)throw new TypeError("Arguments to path.resolve must be strings");if(!b)return"";a=b+"/"+a;b="/"===b.charAt(0)}a=yb(a.split("/").filter(function(d){return!!d}),!b).join("/");return(b?"/":"")+a||"."}var Fb=[];function Gb(a,b){Fb[a]={input:[],output:[],cb:b};Hb(a,Ib)} +var Ib={open:function(a){var b=Fb[a.node.rdev];if(!b)throw new O(43);a.tty=b;a.seekable=!1},close:function(a){a.tty.cb.flush(a.tty)},flush:function(a){a.tty.cb.flush(a.tty)},read:function(a,b,c,d){if(!a.tty||!a.tty.cb.xb)throw new O(60);for(var f=0,g=0;g=b||(b=Math.max(b,c*(1048576>c?2:1.125)>>>0),0!=c&&(b=Math.max(b,256)),c=a.Ma,a.Ma=new Uint8Array(b),0b)a.Ma.length=b;else for(;a.Ma.length=a.node.Sa)return 0;a=Math.min(a.node.Sa-f,d);if(8b)throw new O(28);return b},sb:function(a,b,c){P.vb(a.node,b+c);a.node.Sa=Math.max(a.node.Sa,b+c)},hb:function(a,b,c,d,f,g){assert(0===b);if(32768!==(a.node.mode&61440))throw new O(43);a=a.node.Ma; +if(g&2||a.buffer!==Xa){if(0>>0)%T.length}function Wb(a){var b=Vb(a.parent.id,a.name);if(T[b]===a)T[b]=a.bb;else for(b=T[b];b;){if(b.bb===a){b.bb=a.bb;break}b=b.bb}} +function Ob(a,b){var c;if(c=(c=Xb(a,"x"))?c:a.Na.lookup?0:2)throw new O(c,a);for(c=T[Vb(a.id,b)];c;c=c.bb){var d=c.name;if(c.parent.id===a.id&&d===b)return c}return a.Na.lookup(a,b)}function Mb(a,b,c,d){a=new Yb(a,b,c,d);b=Vb(a.parent.id,a.name);a.bb=T[b];return T[b]=a}function Q(a){return 16384===(a&61440)}var Zb={r:0,rs:1052672,"r+":2,w:577,wx:705,xw:705,"w+":578,"wx+":706,"xw+":706,a:1089,ax:1217,xa:1217,"a+":1090,"ax+":1218,"xa+":1218}; +function $b(a){var b=["r","w","rw"][a&3];a&512&&(b+="w");return b}function Xb(a,b){if(Sb)return 0;if(-1===b.indexOf("r")||a.mode&292){if(-1!==b.indexOf("w")&&!(a.mode&146)||-1!==b.indexOf("x")&&!(a.mode&73))return 2}else return 2;return 0}function ac(a,b){try{return Ob(a,b),20}catch(c){}return Xb(a,"wx")}function bc(a,b,c){try{var d=Ob(a,b)}catch(f){return f.Pa}if(a=Xb(a,"wx"))return a;if(c){if(!Q(d.mode))return 54;if(d===d.parent||"/"===Ub(d))return 10}else if(Q(d.mode))return 31;return 0} +function cc(a){var b=4096;for(a=a||0;a<=b;a++)if(!S[a])return a;throw new O(33);}function dc(a,b){ec||(ec=function(){},ec.prototype={});var c=new ec,d;for(d in a)c[d]=a[d];a=c;b=cc(b);a.fd=b;return S[b]=a}var Lb={open:function(a){a.Oa=Qb[a.node.rdev].Oa;a.Oa.open&&a.Oa.open(a)},Za:function(){throw new O(70);}};function Hb(a,b){Qb[a]={Oa:b}} +function fc(a,b){var c="/"===b,d=!b;if(c&&Pb)throw new O(10);if(!c&&!d){var f=V(b,{wb:!1});b=f.path;f=f.node;if(f.ab)throw new O(10);if(!Q(f.mode))throw new O(54);}b={type:a,Ub:{},yb:b,Mb:[]};a=a.Wa(b);a.Wa=b;b.root=a;c?Pb=a:f&&(f.ab=b,f.Wa&&f.Wa.Mb.push(b))}function da(a,b,c){var d=V(a,{parent:!0}).node;a=Ab(a);if(!a||"."===a||".."===a)throw new O(28);var f=ac(d,a);if(f)throw new O(f);if(!d.Na.gb)throw new O(63);return d.Na.gb(d,a,b,c)}function W(a,b){da(a,(void 0!==b?b:511)&1023|16384,0)} +function hc(a,b,c){"undefined"===typeof c&&(c=b,b=438);da(a,b|8192,c)}function ic(a,b){if(!Eb(a))throw new O(44);var c=V(b,{parent:!0}).node;if(!c)throw new O(44);b=Ab(b);var d=ac(c,b);if(d)throw new O(d);if(!c.Na.symlink)throw new O(63);c.Na.symlink(c,b,a)} +function ta(a){var b=V(a,{parent:!0}).node,c=Ab(a),d=Ob(b,c),f=bc(b,c,!1);if(f)throw new O(f);if(!b.Na.unlink)throw new O(63);if(d.ab)throw new O(10);try{U.willDeletePath&&U.willDeletePath(a)}catch(g){J("FS.trackingDelegate['willDeletePath']('"+a+"') threw an exception: "+g.message)}b.Na.unlink(b,c);Wb(d);try{if(U.onDeletePath)U.onDeletePath(a)}catch(g){J("FS.trackingDelegate['onDeletePath']('"+a+"') threw an exception: "+g.message)}} +function Tb(a){a=V(a).node;if(!a)throw new O(44);if(!a.Na.readlink)throw new O(28);return Eb(Ub(a.parent),a.Na.readlink(a))}function jc(a,b){a=V(a,{Ya:!b}).node;if(!a)throw new O(44);if(!a.Na.Ua)throw new O(63);return a.Na.Ua(a)}function kc(a){return jc(a,!0)}function ea(a,b){var c;"string"===typeof a?c=V(a,{Ya:!0}).node:c=a;if(!c.Na.Ta)throw new O(63);c.Na.Ta(c,{mode:b&4095|c.mode&-4096,timestamp:Date.now()})} +function lc(a){var b;"string"===typeof a?b=V(a,{Ya:!0}).node:b=a;if(!b.Na.Ta)throw new O(63);b.Na.Ta(b,{timestamp:Date.now()})}function mc(a,b){if(0>b)throw new O(28);var c;"string"===typeof a?c=V(a,{Ya:!0}).node:c=a;if(!c.Na.Ta)throw new O(63);if(Q(c.mode))throw new O(31);if(32768!==(c.mode&61440))throw new O(28);if(a=Xb(c,"w"))throw new O(a);c.Na.Ta(c,{size:b,timestamp:Date.now()})} +function u(a,b,c,d){if(""===a)throw new O(44);if("string"===typeof b){var f=Zb[b];if("undefined"===typeof f)throw Error("Unknown file open mode: "+b);b=f}c=b&64?("undefined"===typeof c?438:c)&4095|32768:0;if("object"===typeof a)var g=a;else{a=r(a);try{g=V(a,{Ya:!(b&131072)}).node}catch(n){}}f=!1;if(b&64)if(g){if(b&128)throw new O(20);}else g=da(a,c,0),f=!0;if(!g)throw new O(44);8192===(g.mode&61440)&&(b&=-513);if(b&65536&&!Q(g.mode))throw new O(54);if(!f&&(c=g?40960===(g.mode&61440)?32:Q(g.mode)&& +("r"!==$b(b)||b&512)?31:Xb(g,$b(b)):44))throw new O(c);b&512&&mc(g,0);b&=-131713;d=dc({node:g,path:Ub(g),flags:b,seekable:!0,position:0,Oa:g.Oa,Rb:[],error:!1},d);d.Oa.open&&d.Oa.open(d);!e.logReadFiles||b&1||(Pc||(Pc={}),a in Pc||(Pc[a]=1,J("FS.trackingDelegate error on read file: "+a)));try{U.onOpenFile&&(g=0,1!==(b&2097155)&&(g|=1),0!==(b&2097155)&&(g|=2),U.onOpenFile(a,g))}catch(n){J("FS.trackingDelegate['onOpenFile']('"+a+"', flags) threw an exception: "+n.message)}return d} +function ka(a){if(null===a.fd)throw new O(8);a.ob&&(a.ob=null);try{a.Oa.close&&a.Oa.close(a)}catch(b){throw b;}finally{S[a.fd]=null}a.fd=null}function Qc(a,b,c){if(null===a.fd)throw new O(8);if(!a.seekable||!a.Oa.Za)throw new O(70);if(0!=c&&1!=c&&2!=c)throw new O(28);a.position=a.Oa.Za(a,b,c);a.Rb=[]} +function Sc(a,b,c,d,f){if(0>d||0>f)throw new O(28);if(null===a.fd)throw new O(8);if(1===(a.flags&2097155))throw new O(8);if(Q(a.node.mode))throw new O(31);if(!a.Oa.read)throw new O(28);var g="undefined"!==typeof f;if(!g)f=a.position;else if(!a.seekable)throw new O(70);b=a.Oa.read(a,b,c,d,f);g||(a.position+=b);return b} +function fa(a,b,c,d,f,g){if(0>d||0>f)throw new O(28);if(null===a.fd)throw new O(8);if(0===(a.flags&2097155))throw new O(8);if(Q(a.node.mode))throw new O(31);if(!a.Oa.write)throw new O(28);a.seekable&&a.flags&1024&&Qc(a,0,2);var n="undefined"!==typeof f;if(!n)f=a.position;else if(!a.seekable)throw new O(70);b=a.Oa.write(a,b,c,d,f,g);n||(a.position+=b);try{if(a.path&&U.onWriteToFile)U.onWriteToFile(a.path)}catch(t){J("FS.trackingDelegate['onWriteToFile']('"+a.path+"') threw an exception: "+t.message)}return b} +function sa(a){var b={encoding:"binary"};b=b||{};b.flags=b.flags||"r";b.encoding=b.encoding||"binary";if("utf8"!==b.encoding&&"binary"!==b.encoding)throw Error('Invalid encoding type "'+b.encoding+'"');var c,d=u(a,b.flags);a=jc(a).size;var f=new Uint8Array(a);Sc(d,f,0,a,0);"utf8"===b.encoding?c=Va(f,0):"binary"===b.encoding&&(c=f);ka(d);return c} +function Tc(){O||(O=function(a,b){this.node=b;this.Qb=function(c){this.Pa=c};this.Qb(a);this.message="FS error"},O.prototype=Error(),O.prototype.constructor=O,[44].forEach(function(a){Nb[a]=new O(a);Nb[a].stack=""}))}var Uc;function ca(a,b){var c=0;a&&(c|=365);b&&(c|=146);return c} +function Vc(a,b,c){a=r("/dev/"+a);var d=ca(!!b,!!c);Wc||(Wc=64);var f=Wc++<<8|0;Hb(f,{open:function(g){g.seekable=!1},close:function(){c&&c.buffer&&c.buffer.length&&c(10)},read:function(g,n,t,w){for(var v=0,C=0;C>2]=d.dev;L[c+4>>2]=0;L[c+8>>2]=d.ino;L[c+12>>2]=d.mode;L[c+16>>2]=d.nlink;L[c+20>>2]=d.uid;L[c+24>>2]=d.gid;L[c+28>>2]=d.rdev;L[c+32>>2]=0;M=[d.size>>>0,(N=d.size,1<=+Math.abs(N)?0>>0:~~+Math.ceil((N-+(~~N>>>0))/4294967296)>>>0:0)];L[c+40>>2]=M[0];L[c+44>>2]=M[1];L[c+48>>2]=4096;L[c+52>>2]=d.blocks;L[c+56>>2]=d.atime.getTime()/1E3|0;L[c+60>>2]= +0;L[c+64>>2]=d.mtime.getTime()/1E3|0;L[c+68>>2]=0;L[c+72>>2]=d.ctime.getTime()/1E3|0;L[c+76>>2]=0;M=[d.ino>>>0,(N=d.ino,1<=+Math.abs(N)?0>>0:~~+Math.ceil((N-+(~~N>>>0))/4294967296)>>>0:0)];L[c+80>>2]=M[0];L[c+84>>2]=M[1];return 0}var Zc=void 0;function $c(){Zc+=4;return L[Zc-4>>2]}function Z(a){a=S[a];if(!a)throw new O(8);return a}var ad={}; +function bd(){if(!cd){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"===typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:xa||"./this.program"},b;for(b in ad)a[b]=ad[b];var c=[];for(b in a)c.push(b+"="+a[b]);cd=c}return cd}var cd,dd;za?dd=function(){var a=process.hrtime();return 1E3*a[0]+a[1]/1E6}:"undefined"!==typeof dateNow?dd=dateNow:dd=function(){return performance.now()}; +function ed(a){for(var b=dd();dd()-b>2]);L[b>>2]=a.getSeconds();L[b+4>>2]=a.getMinutes();L[b+8>>2]=a.getHours();L[b+12>>2]=a.getDate();L[b+16>>2]=a.getMonth();L[b+20>>2]=a.getFullYear()-1900;L[b+24>>2]=a.getDay();var c=new Date(a.getFullYear(),0,1);L[b+28>>2]=(a.getTime()-c.getTime())/864E5|0;L[b+36>>2]=-(60*a.getTimezoneOffset());var d=(new Date(a.getFullYear(),6,1)).getTimezoneOffset(); +c=c.getTimezoneOffset();a=(d!=c&&a.getTimezoneOffset()==Math.min(c,d))|0;L[b+32>>2]=a;a=L[xb()+(a?4:0)>>2];L[b+40>>2]=a;return b},j:function(a,b){try{a=A(a);if(b&-8)var c=-28;else{var d;(d=V(a,{Ya:!0}).node)?(a="",b&4&&(a+="r"),b&2&&(a+="w"),b&1&&(a+="x"),c=a&&Xb(d,a)?-2:0):c=-44}return c}catch(f){return"undefined"!==typeof X&&f instanceof O||K(f),-f.Pa}},v:function(a,b){try{return a=A(a),ea(a,b),0}catch(c){return"undefined"!==typeof X&&c instanceof O||K(c),-c.Pa}},D:function(a){try{return a=A(a), +lc(a),0}catch(b){return"undefined"!==typeof X&&b instanceof O||K(b),-b.Pa}},w:function(a,b){try{var c=S[a];if(!c)throw new O(8);ea(c.node,b);return 0}catch(d){return"undefined"!==typeof X&&d instanceof O||K(d),-d.Pa}},E:function(a){try{var b=S[a];if(!b)throw new O(8);lc(b.node);return 0}catch(c){return"undefined"!==typeof X&&c instanceof O||K(c),-c.Pa}},c:function(a,b,c){Zc=c;try{var d=Z(a);switch(b){case 0:var f=$c();return 0>f?-28:u(d.path,d.flags,0,f).fd;case 1:case 2:return 0;case 3:return d.flags; +case 4:return f=$c(),d.flags|=f,0;case 12:return f=$c(),La[f+0>>1]=2,0;case 13:case 14:return 0;case 16:case 8:return-28;case 9:return Bb(28),-1;default:return-28}}catch(g){return"undefined"!==typeof X&&g instanceof O||K(g),-g.Pa}},x:function(a,b){try{var c=Z(a);return Yc(jc,c.path,b)}catch(d){return"undefined"!==typeof X&&d instanceof O||K(d),-d.Pa}},i:function(a,b,c){try{var d=S[a];if(!d)throw new O(8);if(0===(d.flags&2097155))throw new O(28);mc(d.node,c);return 0}catch(f){return"undefined"!==typeof X&& +f instanceof O||K(f),-f.Pa}},J:function(a,b){try{if(0===b)return-28;if(b=c)var d=-28;else{var f=Tb(a),g=Math.min(c,aa(f)),n=z[b+g];k(f,m,b,c+1);z[b+g]=n;d=g}return d}catch(t){return"undefined"!==typeof X&&t instanceof O||K(t),-t.Pa}},C:function(a){try{a=A(a);var b=V(a,{parent:!0}).node,c=Ab(a),d=Ob(b,c),f=bc(b,c,!0);if(f)throw new O(f);if(!b.Na.rmdir)throw new O(63);if(d.ab)throw new O(10);try{U.willDeletePath&&U.willDeletePath(a)}catch(g){J("FS.trackingDelegate['willDeletePath']('"+a+"') threw an exception: "+ +g.message)}b.Na.rmdir(b,c);Wb(d);try{if(U.onDeletePath)U.onDeletePath(a)}catch(g){J("FS.trackingDelegate['onDeletePath']('"+a+"') threw an exception: "+g.message)}return 0}catch(g){return"undefined"!==typeof X&&g instanceof O||K(g),-g.Pa}},f:function(a,b){try{return a=A(a),Yc(jc,a,b)}catch(c){return"undefined"!==typeof X&&c instanceof O||K(c),-c.Pa}},H:function(a){try{return a=A(a),ta(a),0}catch(b){return"undefined"!==typeof X&&b instanceof O||K(b),-b.Pa}},n:function(a,b,c){m.copyWithin(a,b,b+c)}, +d:function(a){a>>>=0;var b=m.length;if(2147483648=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);d=Math.max(16777216,a,d);0>>16);Ya(Oa.buffer);var f=1;break a}catch(g){}f=void 0}if(f)return!0}return!1},p:function(a,b){var c=0;bd().forEach(function(d,f){var g=b+c;f=L[a+4*f>>2]=g;for(g=0;g>0]=d.charCodeAt(g);z[f>>0]=0;c+=d.length+1});return 0},q:function(a,b){var c= +bd();L[a>>2]=c.length;var d=0;c.forEach(function(f){d+=f.length+1});L[b>>2]=d;return 0},g:function(a){try{var b=Z(a);ka(b);return 0}catch(c){return"undefined"!==typeof X&&c instanceof O||K(c),c.Pa}},o:function(a,b){try{var c=Z(a);z[b>>0]=c.tty?2:Q(c.mode)?3:40960===(c.mode&61440)?7:4;return 0}catch(d){return"undefined"!==typeof X&&d instanceof O||K(d),d.Pa}},m:function(a,b,c,d,f){try{var g=Z(a);a=4294967296*c+(b>>>0);if(-9007199254740992>=a||9007199254740992<=a)return-61;Qc(g,a,d);M=[g.position>>> +0,(N=g.position,1<=+Math.abs(N)?0>>0:~~+Math.ceil((N-+(~~N>>>0))/4294967296)>>>0:0)];L[f>>2]=M[0];L[f+4>>2]=M[1];g.ob&&0===a&&0===d&&(g.ob=null);return 0}catch(n){return"undefined"!==typeof X&&n instanceof O||K(n),n.Pa}},K:function(a){try{var b=Z(a);return b.Oa&&b.Oa.fsync?-b.Oa.fsync(b):0}catch(c){return"undefined"!==typeof X&&c instanceof O||K(c),c.Pa}},I:function(a,b,c,d){try{a:{for(var f=Z(a),g=a=0;g>2],L[b+(8* +g+4)>>2],void 0);if(0>n){var t=-1;break a}a+=n}t=a}L[d>>2]=t;return 0}catch(w){return"undefined"!==typeof X&&w instanceof O||K(w),w.Pa}},h:function(a){var b=Date.now();L[a>>2]=b/1E3|0;L[a+4>>2]=b%1E3*1E3|0;return 0},a:Oa,k:function(a,b){if(0===a)return Bb(28),-1;var c=L[a>>2];a=L[a+4>>2];if(0>a||999999999c)return Bb(28),-1;0!==b&&(L[b>>2]=0,L[b+4>>2]=0);return ed(1E6*c+a/1E3)},B:function(a){switch(a){case 30:return 16384;case 85:return 131072;case 132:case 133:case 12:case 137:case 138:case 15:case 235:case 16:case 17:case 18:case 19:case 20:case 149:case 13:case 10:case 236:case 153:case 9:case 21:case 22:case 159:case 154:case 14:case 77:case 78:case 139:case 80:case 81:case 82:case 68:case 67:case 164:case 11:case 29:case 47:case 48:case 95:case 52:case 51:case 46:case 79:return 200809; +case 27:case 246:case 127:case 128:case 23:case 24:case 160:case 161:case 181:case 182:case 242:case 183:case 184:case 243:case 244:case 245:case 165:case 178:case 179:case 49:case 50:case 168:case 169:case 175:case 170:case 171:case 172:case 97:case 76:case 32:case 173:case 35:return-1;case 176:case 177:case 7:case 155:case 8:case 157:case 125:case 126:case 92:case 93:case 129:case 130:case 131:case 94:case 91:return 1;case 74:case 60:case 69:case 70:case 4:return 1024;case 31:case 42:case 72:return 32; +case 87:case 26:case 33:return 2147483647;case 34:case 1:return 47839;case 38:case 36:return 99;case 43:case 37:return 2048;case 0:return 2097152;case 3:return 65536;case 28:return 32768;case 44:return 32767;case 75:return 16384;case 39:return 1E3;case 89:return 700;case 71:return 256;case 40:return 255;case 2:return 100;case 180:return 64;case 25:return 20;case 5:return 16;case 6:return 6;case 73:return 4;case 84:return"object"===typeof navigator?navigator.hardwareConcurrency||1:1}Bb(28);return-1}, +L:function(a){var b=Date.now()/1E3|0;a&&(L[a>>2]=b);return b},s:function(a,b){if(b){var c=1E3*L[b+8>>2];c+=L[b+12>>2]/1E3}else c=Date.now();a=A(a);try{b=c;var d=V(a,{Ya:!0}).node;d.Na.Ta(d,{timestamp:Math.max(b,c)});return 0}catch(f){a=f;if(!(a instanceof O)){a+=" : ";a:{d=Error();if(!d.stack){try{throw Error();}catch(g){d=g}if(!d.stack){d="(no stack trace available)";break a}}d=d.stack.toString()}e.extraStackTrace&&(d+="\n"+e.extraStackTrace());d=ob(d);throw a+d;}Bb(a.Pa);return-1}}}; +(function(){function a(f){e.asm=f.exports;Ja=e.asm.M;eb--;e.monitorRunDependencies&&e.monitorRunDependencies(eb);0==eb&&(null!==fb&&(clearInterval(fb),fb=null),gb&&(f=gb,gb=null,f()))}function b(f){a(f.instance)}function c(f){return mb().then(function(g){return WebAssembly.instantiate(g,d)}).then(f,function(g){J("failed to asynchronously prepare wasm: "+g);K(g)})}var d={a:id};eb++;e.monitorRunDependencies&&e.monitorRunDependencies(eb);if(e.instantiateWasm)try{return e.instantiateWasm(d,a)}catch(f){return J("Module.instantiateWasm callback failed with error: "+ +f),!1}(function(){if(Ka||"function"!==typeof WebAssembly.instantiateStreaming||jb()||hb("file://")||"function"!==typeof fetch)return c(b);fetch(ib,{credentials:"same-origin"}).then(function(f){return WebAssembly.instantiateStreaming(f,d).then(b,function(g){J("wasm streaming compile failed: "+g);J("falling back to ArrayBuffer instantiation");return c(b)})})})();return{}})(); +var fd=e.___wasm_call_ctors=function(){return(fd=e.___wasm_call_ctors=e.asm.N).apply(null,arguments)},hd=e._memset=function(){return(hd=e._memset=e.asm.O).apply(null,arguments)};e._sqlite3_free=function(){return(e._sqlite3_free=e.asm.P).apply(null,arguments)};var Cb=e.___errno_location=function(){return(Cb=e.___errno_location=e.asm.Q).apply(null,arguments)};e._sqlite3_finalize=function(){return(e._sqlite3_finalize=e.asm.R).apply(null,arguments)}; +e._sqlite3_reset=function(){return(e._sqlite3_reset=e.asm.S).apply(null,arguments)};e._sqlite3_clear_bindings=function(){return(e._sqlite3_clear_bindings=e.asm.T).apply(null,arguments)};e._sqlite3_value_blob=function(){return(e._sqlite3_value_blob=e.asm.U).apply(null,arguments)};e._sqlite3_value_text=function(){return(e._sqlite3_value_text=e.asm.V).apply(null,arguments)};e._sqlite3_value_bytes=function(){return(e._sqlite3_value_bytes=e.asm.W).apply(null,arguments)}; +e._sqlite3_value_double=function(){return(e._sqlite3_value_double=e.asm.X).apply(null,arguments)};e._sqlite3_value_int=function(){return(e._sqlite3_value_int=e.asm.Y).apply(null,arguments)};e._sqlite3_value_type=function(){return(e._sqlite3_value_type=e.asm.Z).apply(null,arguments)};e._sqlite3_result_blob=function(){return(e._sqlite3_result_blob=e.asm._).apply(null,arguments)};e._sqlite3_result_double=function(){return(e._sqlite3_result_double=e.asm.$).apply(null,arguments)}; +e._sqlite3_result_error=function(){return(e._sqlite3_result_error=e.asm.aa).apply(null,arguments)};e._sqlite3_result_int=function(){return(e._sqlite3_result_int=e.asm.ba).apply(null,arguments)};e._sqlite3_result_int64=function(){return(e._sqlite3_result_int64=e.asm.ca).apply(null,arguments)};e._sqlite3_result_null=function(){return(e._sqlite3_result_null=e.asm.da).apply(null,arguments)};e._sqlite3_result_text=function(){return(e._sqlite3_result_text=e.asm.ea).apply(null,arguments)}; +e._sqlite3_step=function(){return(e._sqlite3_step=e.asm.fa).apply(null,arguments)};e._sqlite3_column_count=function(){return(e._sqlite3_column_count=e.asm.ga).apply(null,arguments)};e._sqlite3_data_count=function(){return(e._sqlite3_data_count=e.asm.ha).apply(null,arguments)};e._sqlite3_column_blob=function(){return(e._sqlite3_column_blob=e.asm.ia).apply(null,arguments)};e._sqlite3_column_bytes=function(){return(e._sqlite3_column_bytes=e.asm.ja).apply(null,arguments)}; +e._sqlite3_column_double=function(){return(e._sqlite3_column_double=e.asm.ka).apply(null,arguments)};e._sqlite3_column_text=function(){return(e._sqlite3_column_text=e.asm.la).apply(null,arguments)};e._sqlite3_column_type=function(){return(e._sqlite3_column_type=e.asm.ma).apply(null,arguments)};e._sqlite3_column_name=function(){return(e._sqlite3_column_name=e.asm.na).apply(null,arguments)};e._sqlite3_bind_blob=function(){return(e._sqlite3_bind_blob=e.asm.oa).apply(null,arguments)}; +e._sqlite3_bind_double=function(){return(e._sqlite3_bind_double=e.asm.pa).apply(null,arguments)};e._sqlite3_bind_int=function(){return(e._sqlite3_bind_int=e.asm.qa).apply(null,arguments)};e._sqlite3_bind_text=function(){return(e._sqlite3_bind_text=e.asm.ra).apply(null,arguments)};e._sqlite3_bind_parameter_index=function(){return(e._sqlite3_bind_parameter_index=e.asm.sa).apply(null,arguments)};e._sqlite3_sql=function(){return(e._sqlite3_sql=e.asm.ta).apply(null,arguments)}; +e._sqlite3_normalized_sql=function(){return(e._sqlite3_normalized_sql=e.asm.ua).apply(null,arguments)};e._sqlite3_errmsg=function(){return(e._sqlite3_errmsg=e.asm.va).apply(null,arguments)};e._sqlite3_exec=function(){return(e._sqlite3_exec=e.asm.wa).apply(null,arguments)};e._sqlite3_prepare_v2=function(){return(e._sqlite3_prepare_v2=e.asm.xa).apply(null,arguments)};e._sqlite3_changes=function(){return(e._sqlite3_changes=e.asm.ya).apply(null,arguments)}; +e._sqlite3_close_v2=function(){return(e._sqlite3_close_v2=e.asm.za).apply(null,arguments)};e._sqlite3_create_function_v2=function(){return(e._sqlite3_create_function_v2=e.asm.Aa).apply(null,arguments)};e._sqlite3_open=function(){return(e._sqlite3_open=e.asm.Ba).apply(null,arguments)};var ba=e._malloc=function(){return(ba=e._malloc=e.asm.Ca).apply(null,arguments)},na=e._free=function(){return(na=e._free=e.asm.Da).apply(null,arguments)}; +e._RegisterExtensionFunctions=function(){return(e._RegisterExtensionFunctions=e.asm.Ea).apply(null,arguments)}; +var xb=e.__get_tzname=function(){return(xb=e.__get_tzname=e.asm.Fa).apply(null,arguments)},vb=e.__get_daylight=function(){return(vb=e.__get_daylight=e.asm.Ga).apply(null,arguments)},ub=e.__get_timezone=function(){return(ub=e.__get_timezone=e.asm.Ha).apply(null,arguments)},oa=e.stackSave=function(){return(oa=e.stackSave=e.asm.Ia).apply(null,arguments)},qa=e.stackRestore=function(){return(qa=e.stackRestore=e.asm.Ja).apply(null,arguments)},y=e.stackAlloc=function(){return(y=e.stackAlloc=e.asm.Ka).apply(null, +arguments)},gd=e._memalign=function(){return(gd=e._memalign=e.asm.La).apply(null,arguments)};e.cwrap=function(a,b,c,d){c=c||[];var f=c.every(function(g){return"number"===g});return"string"!==b&&f&&!d?Qa(a):function(){return Ra(a,b,c,arguments)}};e.UTF8ToString=A;e.stackSave=oa;e.stackRestore=qa;e.stackAlloc=y;var jd;gb=function kd(){jd||ld();jd||(gb=kd)}; +function ld(){function a(){if(!jd&&(jd=!0,e.calledRun=!0,!Pa)){e.noFSInit||Uc||(Uc=!0,Tc(),e.stdin=e.stdin,e.stdout=e.stdout,e.stderr=e.stderr,e.stdin?Vc("stdin",e.stdin):ic("/dev/tty","/dev/stdin"),e.stdout?Vc("stdout",null,e.stdout):ic("/dev/tty","/dev/stdout"),e.stderr?Vc("stderr",null,e.stderr):ic("/dev/tty1","/dev/stderr"),u("/dev/stdin","r"),u("/dev/stdout","w"),u("/dev/stderr","w"));nb(ab);Sb=!1;nb(bb);if(e.onRuntimeInitialized)e.onRuntimeInitialized();if(e.postRun)for("function"==typeof e.postRun&& +(e.postRun=[e.postRun]);e.postRun.length;){var b=e.postRun.shift();cb.unshift(b)}nb(cb)}}if(!(0 onReturnAction; + final VoidCallback onAttachmentTap; const VideoAttachment({ Key key, @@ -20,6 +21,7 @@ class VideoAttachment extends AttachmentWidget { this.messageTheme, this.onShowMessage, this.onReturnAction, + this.onAttachmentTap, }) : super(key: key, message: message, attachment: attachment, size: size); @override @@ -65,25 +67,26 @@ class VideoAttachment extends AttachmentWidget { children: [ Expanded( child: GestureDetector( - onTap: () async { - final channel = StreamChannel.of(context).channel; - final res = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [attachment], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, + onTap: onAttachmentTap ?? + () async { + final channel = StreamChannel.of(context).channel; + final res = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ), ), - ), - ), - ); - if (res != null) onReturnAction(res); - }, + ); + if (res != null) onReturnAction(res); + }, child: Stack( children: [ videoWidget, diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart new file mode 100644 index 000000000..d9b9c016a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart @@ -0,0 +1,324 @@ +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../stream_chat_flutter.dart'; +import 'extension.dart'; + +/// Callback to download an attachment asset +typedef AttachmentDownloader = Future Function( + Attachment attachment, { + ProgressCallback progressCallback, +}); + +/// Widget that shows the options in the gallery view +class AttachmentActionsModal extends StatelessWidget { + /// The message containing the attachments + final Message message; + + /// Current page index + final currentIndex; + + /// Callback to show the message + final VoidCallback onShowMessage; + + /// Callback to download images + final AttachmentDownloader imageDownloader; + + /// Callback to provide download files + final AttachmentDownloader fileDownloader; + + /// Returns a new [AttachmentActionsModal] + const AttachmentActionsModal({ + this.message, + this.currentIndex, + this.onShowMessage, + this.imageDownloader, + this.fileDownloader, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: _buildPage(context), + ); + } + + Widget _buildPage(context) { + final theme = StreamChatTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(height: kToolbarHeight), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + width: MediaQuery.of(context).size.width * 0.5, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + ), + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _buildButton( + context, + 'Reply', + StreamSvgIcon.iconCurveLineLeftUp( + size: 24.0, + color: theme.colorTheme.grey, + ), + () { + Navigator.pop(context, ReturnActionType.reply); + }, + ), + _buildButton( + context, + 'Show in Chat', + StreamSvgIcon.eye( + size: 24.0, + color: theme.colorTheme.black, + ), + onShowMessage, + ), + _buildButton( + context, + 'Save ${message.attachments[currentIndex].type == 'video' ? 'Video' : 'Image'}', + StreamSvgIcon.iconSave( + size: 24.0, + color: theme.colorTheme.grey, + ), + () { + final attachment = message.attachments[currentIndex]; + final isImage = attachment.type == 'image'; + final saveFile = fileDownloader ?? _downloadAttachment; + final saveImage = imageDownloader ?? _downloadAttachment; + final downloader = isImage ? saveImage : saveFile; + + final progressNotifier = ValueNotifier<_DownloadProgress>( + _DownloadProgress.initial(), + ); + + downloader( + attachment, + progressCallback: (received, total) { + progressNotifier.value = _DownloadProgress( + total, + received, + ); + }, + ).catchError((_) { + progressNotifier.value = null; + }); + + // Closing attachment actions modal before opening + // attachment download dialog + Navigator.pop(context); + + showDialog( + barrierDismissible: false, + context: context, + barrierColor: theme.colorTheme.overlay, + builder: (context) => _buildDownloadProgressDialog( + context, + progressNotifier, + ), + ); + }, + ), + if (StreamChat.of(context).user.id == message.user.id) + _buildButton( + context, + 'Delete', + StreamSvgIcon.delete( + size: 24.0, + color: theme.colorTheme.accentRed, + ), + () { + final channel = StreamChannel.of(context).channel; + if (message.attachments.length > 1 || + message.text.isNotEmpty) { + final remainingAttachments = [...message.attachments] + ..removeAt(currentIndex); + channel.updateMessage(message.copyWith( + attachments: remainingAttachments, + )); + Navigator.pop(context); + Navigator.pop(context); + } else { + channel.deleteMessage(message).then((value) { + Navigator.pop(context); + Navigator.pop(context); + }); + } + }, + color: theme.colorTheme.accentRed, + ), + ] + .map((e) => + Align(alignment: Alignment.centerRight, child: e)) + .insertBetween( + Container( + height: 1, + color: theme.colorTheme.greyWhisper, + ), + ), + ), + ), + ), + ) + ], + ); + } + + Widget _buildButton( + context, + String title, + StreamSvgIcon icon, + VoidCallback onTap, { + Color color, + }) { + return Material( + color: StreamChatTheme.of(context).colorTheme.white, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Row( + children: [ + icon, + SizedBox(width: 16), + Text( + title, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(color: color), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDownloadProgressDialog( + BuildContext context, + ValueNotifier<_DownloadProgress> progressNotifier, + ) { + final theme = StreamChatTheme.of(context); + return WillPopScope( + onWillPop: () => Future.value(false), + child: ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, _DownloadProgress progress, __) { + // Pop the dialog in case the progress is null or it's completed. + if (progress == null || progress?.toProgressIndicatorValue == 1.0) { + Future.delayed( + const Duration(milliseconds: 500), + Navigator.of(context).pop, + ); + } + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + height: 182, + width: 182, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorTheme.white, + ), + child: Center( + child: progress == null + ? Container( + height: 100, + width: 100, + child: StreamSvgIcon.error( + color: theme.colorTheme.greyGainsboro, + ), + ) + : progress.toProgressIndicatorValue == 1.0 + ? Container( + height: 160, + width: 160, + child: StreamSvgIcon.check( + color: theme.colorTheme.greyGainsboro, + ), + ) + : Container( + height: 100, + width: 100, + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: progress.toProgressIndicatorValue, + strokeWidth: 8.0, + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentBlue, + ), + ), + Center( + child: Text( + '${progress.toPercentage}%', + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.grey, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ); + } + + Future _downloadAttachment( + Attachment attachment, { + ProgressCallback progressCallback, + }) async { + String filePath; + final appDocDir = await getTemporaryDirectory(); + await Dio().download( + attachment.assetUrl ?? attachment.imageUrl ?? attachment.thumbUrl, + (Headers responseHeaders) { + final contentType = responseHeaders[Headers.contentTypeHeader]; + final mimeType = contentType.first?.split('/')?.last; + filePath ??= '${appDocDir.path}/${attachment.id}.$mimeType'; + return filePath; + }, + onReceiveProgress: progressCallback, + ); + final result = await ImageGallerySaver.saveFile(filePath); + return (result as Map)['filePath']; + } +} + +class _DownloadProgress { + final int total; + final int received; + + const _DownloadProgress(this.total, this.received); + + factory _DownloadProgress.initial() { + return _DownloadProgress(double.maxFinite.toInt(), 0); + } + + double get toProgressIndicatorValue => received / total; + + int get toPercentage => (received * 100) ~/ total; +} diff --git a/packages/stream_chat_flutter/lib/src/back_button.dart b/packages/stream_chat_flutter/lib/src/back_button.dart index 9d7875176..f49fa98cb 100644 --- a/packages/stream_chat_flutter/lib/src/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/back_button.dart @@ -21,28 +21,28 @@ class StreamBackButton extends StatelessWidget { @override Widget build(BuildContext context) { return Stack( + alignment: Alignment.center, children: [ - Padding( + RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: () { + if (onPressed != null) { + onPressed(); + } else { + Navigator.maybePop(context); + } + }, padding: const EdgeInsets.all(14.0), - child: RawMaterialButton( - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: () { - if (onPressed != null) { - onPressed(); - } else { - Navigator.maybePop(context); - } - }, - child: StreamSvgIcon.left( - size: 24, - color: StreamChatTheme.of(context).colorTheme.black, - ), + child: StreamSvgIcon.left( + size: 24, + color: StreamChatTheme.of(context).colorTheme.black, ), ), if (showUnreads) diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart index 57b27a2fd..2f3a26b27 100644 --- a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -114,9 +114,9 @@ class _ChannelBottomSheetState extends State { children: [ UserAvatar( user: members[index].user, - constraints: BoxConstraints( - maxHeight: 64.0, - maxWidth: 64.0, + constraints: BoxConstraints.tightFor( + height: 64.0, + width: 64.0, ), borderRadius: BorderRadius.circular(32.0), onlineIndicatorConstraints: @@ -238,8 +238,8 @@ class _ChannelBottomSheetState extends State { color: StreamChatTheme.of(context).colorTheme.accentRed, ), ); - var channel = StreamChannel.of(context).channel; if (res == true) { + final channel = StreamChannel.of(context).channel; await channel.removeMembers([StreamChat.of(context).user.id]); Navigator.pop(context); } 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 a6be5555b..2813f300b 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 @@ -71,12 +71,12 @@ class _ChannelFileDisplayScreenState extends State { Navigator.of(context).pop(); }, child: Container( + width: 24.0, + height: 24.0, child: StreamSvgIcon.left( color: StreamChatTheme.of(context).colorTheme.black, size: 24.0, ), - width: 24.0, - height: 24.0, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart index 6845a6324..c2927a6ee 100644 --- a/packages/stream_chat_flutter/lib/src/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -71,6 +71,19 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { final bool showConnectionStateTile; + /// Title widget + final Widget title; + + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + final Widget leading; + + /// AppBar actions + /// By default it shows the [ChannelImage] + final List actions; + /// Creates a channel header ChannelHeader({ Key key, @@ -80,6 +93,10 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { this.showTypingIndicator = true, this.onImageTap, this.showConnectionStateTile = false, + this.title, + this.subtitle, + this.leading, + this.actions, }) : preferredSize = Size.fromHeight(kToolbarHeight), super(key: key); @@ -87,6 +104,14 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; + final leadingWidget = leading ?? + (showBackButton + ? StreamBackButton( + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox()); + return ConnectionStatusBuilder( statusBuilder: (context, status) { var statusString = ''; @@ -111,26 +136,32 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { child: AppBar( brightness: Theme.of(context).brightness, elevation: 1, - leading: showBackButton - ? StreamBackButton( - onPressed: onBackPressed, - showUnreads: true, - ) - : SizedBox(), + leading: leadingWidget, backgroundColor: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme .color, - actions: [ - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Center( - child: ChannelImage( - onTap: onImageTap, + actions: actions ?? + [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Center( + child: ChannelImage( + borderRadius: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .avatarTheme + .borderRadius, + constraints: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .avatarTheme + .constraints, + onTap: onImageTap, + ), + ), ), - ), - ), - ], + ], centerTitle: true, title: InkWell( onTap: onTitleTap, @@ -141,20 +172,23 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), + title ?? + ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), SizedBox(height: 2), - ChannelInfo( - showTypingIndicator: showTypingIndicator, - channel: channel, - textStyle: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle, - ), + subtitle ?? + ChannelInfo( + showTypingIndicator: showTypingIndicator, + channel: channel, + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_image.dart b/packages/stream_chat_flutter/lib/src/channel_image.dart index 649a58808..e7e39d385 100644 --- a/packages/stream_chat_flutter/lib/src/channel_image.dart +++ b/packages/stream_chat_flutter/lib/src/channel_image.dart @@ -96,7 +96,11 @@ class ChannelImage extends StatelessWidget { initialData: otherMember.user, builder: (context, snapshot) { return UserAvatar( - borderRadius: borderRadius, + borderRadius: borderRadius ?? + StreamChatTheme.of(context) + .channelPreviewTheme + .avatarTheme + .borderRadius, user: snapshot.data ?? otherMember.user, constraints: constraints ?? StreamChatTheme.of(context) @@ -120,7 +124,11 @@ class ChannelImage extends StatelessWidget { .toList(); return GroupImage( images: images, - borderRadius: borderRadius, + borderRadius: borderRadius ?? + StreamChatTheme.of(context) + .channelPreviewTheme + .avatarTheme + .borderRadius, constraints: constraints ?? StreamChatTheme.of(context) .channelPreviewTheme diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 53a621a03..1d6f6fe27 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -57,7 +57,7 @@ class ChannelInfo extends StatelessWidget { style: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme - .lastMessageAt, + .subtitle, ); } else { final otherMember = members.firstWhere( @@ -69,10 +69,7 @@ class ChannelInfo extends StatelessWidget { if (otherMember.user.online) { alternativeWidget = Text( 'Online', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, + style: textStyle, ); } else { alternativeWidget = Text( diff --git a/packages/stream_chat_flutter/lib/src/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel_list_header.dart index cca4170ec..88b329e69 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_header.dart @@ -45,7 +45,7 @@ typedef _TitleBuilder = Widget Function( /// The widget by default uses the inherited [StreamChatClient] to fetch information about the status. /// However you can also pass your own [StreamChatClient] if you don't have it in the widget tree. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelListHeaderTheme] property. /// Modify it to change the widget appearance. class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { /// Instantiates a ChannelListHeader @@ -57,6 +57,9 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { this.onNewChatButtonTap, this.showConnectionStateTile = false, this.preNavigationCallback, + this.subtitle, + this.leading, + this.actions, }) : super(key: key); /// Pass this if you don't have a [StreamChatClient] in your widget tree. @@ -76,6 +79,17 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { final VoidCallback preNavigationCallback; + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + /// By default it shows the logged in user avatar + final Widget leading; + + /// AppBar actions + /// By default it shows the new chat button + final List actions; + @override Widget build(BuildContext context) { final _client = client ?? StreamChat.of(context).client; @@ -104,76 +118,85 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { child: AppBar( brightness: Theme.of(context).brightness, elevation: 1, - backgroundColor: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .color, + backgroundColor: + StreamChatTheme.of(context).channelListHeaderTheme.color, centerTitle: true, - leading: Center( - child: UserAvatar( - user: user, - showOnlineStatus: false, - onTap: onUserAvatarTap ?? - (_) { - if (preNavigationCallback != null) { - preNavigationCallback(); - } - Scaffold.of(context).openDrawer(); - }, - borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - ), - actions: [ - StreamNeumorphicButton( - child: IconButton( - icon: ConnectionStatusBuilder( - statusBuilder: (context, status) { - var color; - switch (status) { - case ConnectionStatus.connected: - color = - StreamChatTheme.of(context).colorTheme.accentBlue; - break; - case ConnectionStatus.connecting: - color = Colors.grey; - break; - case ConnectionStatus.disconnected: - color = Colors.grey; - break; - } - return SvgPicture.asset( - 'svgs/icon_pen_write.svg', - package: 'stream_chat_flutter', - width: 24.0, - height: 24.0, - color: color, - ); - }, + leading: leading ?? + Center( + child: UserAvatar( + user: user, + showOnlineStatus: false, + onTap: onUserAvatarTap ?? + (_) { + if (preNavigationCallback != null) { + preNavigationCallback(); + } + Scaffold.of(context).openDrawer(); + }, + borderRadius: StreamChatTheme.of(context) + .channelListHeaderTheme + .avatarTheme + .borderRadius, + constraints: StreamChatTheme.of(context) + .channelListHeaderTheme + .avatarTheme + .constraints, ), - onPressed: onNewChatButtonTap, ), - ) - ], - title: Builder( - builder: (context) { - if (titleBuilder != null) { - return titleBuilder(context, status, _client); - } - switch (status) { - case ConnectionStatus.connected: - return _buildConnectedTitleState(context); - case ConnectionStatus.connecting: - return _buildConnectingTitleState(context); - case ConnectionStatus.disconnected: - return _buildDisconnectedTitleState(context, _client); - default: - return Offstage(); - } - }, + actions: actions ?? + [ + StreamNeumorphicButton( + child: IconButton( + icon: ConnectionStatusBuilder( + statusBuilder: (context, status) { + var color; + switch (status) { + case ConnectionStatus.connected: + color = StreamChatTheme.of(context) + .colorTheme + .accentBlue; + break; + case ConnectionStatus.connecting: + color = Colors.grey; + break; + case ConnectionStatus.disconnected: + color = Colors.grey; + break; + } + return SvgPicture.asset( + 'svgs/icon_pen_write.svg', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + color: color, + ); + }, + ), + onPressed: onNewChatButtonTap, + ), + ) + ], + title: Column( + children: [ + Builder( + builder: (context) { + if (titleBuilder != null) { + return titleBuilder(context, status, _client); + } + switch (status) { + case ConnectionStatus.connected: + return _buildConnectedTitleState(context); + case ConnectionStatus.connecting: + return _buildConnectingTitleState(context); + case ConnectionStatus.disconnected: + return _buildDisconnectedTitleState(context, _client); + default: + return Offstage(); + } + }, + ), + subtitle ?? Offstage(), + ], ), ), ); @@ -202,14 +225,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { SizedBox(width: 10), Text( 'Searching for Network', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title - .copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: + StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), ], ); @@ -222,14 +242,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { children: [ Text( 'Offline...', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title - .copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: + StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), TextButton( onPressed: () async { @@ -239,8 +256,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { child: Text( 'Try Again', style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme + .channelListHeaderTheme .title .copyWith( fontSize: 16, 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 1fb9885a9..0396bffed 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -223,8 +223,8 @@ class _ChannelListViewState extends State { } return AnimatedSwitcher( - child: child, duration: const Duration(milliseconds: 500), + child: child, ); } @@ -393,16 +393,17 @@ class _ChannelListViewState extends State { subtitle: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(11), - ), - constraints: BoxConstraints.tightFor( - height: 16, - width: 238, + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.expand( + height: 16, + ), ), ), ), @@ -468,8 +469,8 @@ class _ChannelListViewState extends State { MaterialPageRoute( builder: (context) { return StreamChannel( - child: widget.channelWidget, channel: client, + child: widget.channelWidget, ); }, ), @@ -505,12 +506,12 @@ class _ChannelListViewState extends State { context: context, builder: (context) { return StreamChannel( + channel: channel, child: ChannelBottomSheet( onViewInfoTap: () { widget.onViewInfoTap(channel); }, ), - channel: channel, ); }, ); @@ -553,7 +554,7 @@ class _ChannelListViewState extends State { ChannelPreview( onLongPress: widget.onChannelLongPress, channel: channel, - onImageTap: widget.onImageTap?.call(channel), + onImageTap: () => widget.onImageTap?.call(channel), onTap: (channel) => onTap(channel, widget.channelWidget), ), ), @@ -591,13 +592,13 @@ class _ChannelListViewState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: StreamChannel( + channel: channel, child: ChannelName( textStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, ), ), - channel: channel, ), ), ], 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 87ab2f3b9..251143edf 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 @@ -78,12 +78,12 @@ class _ChannelMediaDisplayScreenState extends State { Navigator.of(context).pop(); }, child: Container( + width: 24.0, + height: 24.0, child: StreamSvgIcon.left( color: StreamChatTheme.of(context).colorTheme.black, size: 24.0, ), - width: 24.0, - height: 24.0, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 2d29f12bb..9ba34f3a8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -32,16 +32,37 @@ class ChannelPreview extends StatelessWidget { /// The function called when the image is tapped final VoidCallback onImageTap; + /// Widget rendering the title + final Widget title; + + /// Widget rendering the subtitle + final Widget subtitle; + + /// Widget rendering the leading element, by default it shows the [ChannelImage] + final Widget leading; + + /// Widget rendering the trailing element, by default it shows the last message date + final Widget trailing; + + /// Widget rendering the sending indicator, by default it uses the [SendingIndicator] widget + final Widget sendingIndicator; + ChannelPreview({ @required this.channel, Key key, this.onTap, this.onLongPress, this.onImageTap, + this.title, + this.subtitle, + this.leading, + this.sendingIndicator, + this.trailing, }) : super(key: key); @override Widget build(BuildContext context) { + final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme; return StreamBuilder( stream: channel.isMutedStream, initialData: channel.isMuted, @@ -49,6 +70,7 @@ class ChannelPreview extends StatelessWidget { return Opacity( opacity: snapshot.data ? 0.5 : 1, child: ListTile( + visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric( horizontal: 8, ), @@ -62,17 +84,18 @@ class ChannelPreview extends StatelessWidget { onLongPress(channel); } }, - leading: ChannelImage( - onTap: onImageTap, - ), + leading: leading ?? + ChannelImage( + onTap: onImageTap, + ), title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: ChannelName( - textStyle: - StreamChatTheme.of(context).channelPreviewTheme.title, - ), + child: title ?? + ChannelName( + textStyle: channelPreviewTheme.title, + ), ), StreamBuilder>( stream: channel.state.membersStream, @@ -93,37 +116,36 @@ class ChannelPreview extends StatelessWidget { subtitle: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible(child: _buildSubtitle(context)), - Builder( - builder: (context) { - final lastMessage = channel.state.messages.lastWhere( - (m) => !m.isDeleted && m.shadowed != true, - orElse: () => null, - ); - if (lastMessage?.user?.id == - StreamChat.of(context).user.id) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: SendingIndicator( - message: lastMessage, - size: StreamChatTheme.of(context) - .channelPreviewTheme - .indicatorIconSize, - isMessageRead: channel.state.read - ?.where((element) => - element.user.id != - channel.client.state.user.id) - ?.where((element) => element.lastRead - .isAfter(lastMessage.createdAt)) - ?.isNotEmpty == - true, - ), - ); - } - return SizedBox(); - }, - ), - _buildDate(context), + Flexible(child: subtitle ?? _buildSubtitle(context)), + sendingIndicator ?? + Builder( + builder: (context) { + final lastMessage = channel.state.messages.lastWhere( + (m) => !m.isDeleted && m.shadowed != true, + orElse: () => null, + ); + if (lastMessage?.user?.id == + StreamChat.of(context).user.id) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: SendingIndicator( + message: lastMessage, + size: channelPreviewTheme.indicatorIconSize, + isMessageRead: channel.state.read + ?.where((element) => + element.user.id != + channel.client.state.user.id) + ?.where((element) => element.lastRead + .isAfter(lastMessage.createdAt)) + ?.isNotEmpty == + true, + ), + ); + } + return SizedBox(); + }, + ), + trailing ?? _buildDate(context), ], ), ), @@ -176,15 +198,7 @@ class ChannelPreview extends StatelessWidget { ), Text( ' Channel is muted', - style: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .copyWith( - color: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .color, - ), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, ), ], ); @@ -192,10 +206,7 @@ class ChannelPreview extends StatelessWidget { return TypingIndicator( channel: channel, alternativeWidget: _buildLastMessage(context), - style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - color: - StreamChatTheme.of(context).channelPreviewTheme.subtitle.color, - ), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, ); } diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index f19d851b3..65b1b1006 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,6 +1,7 @@ import 'package:characters/characters.dart'; import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; final _emojis = Emoji.all(); @@ -44,3 +45,55 @@ extension PlatformFileX on PlatformFile { size: size, ); } + +extension InputDecorationX on InputDecoration { + InputDecoration merge(InputDecoration other) { + if (other == null) return this; + return copyWith( + icon: other?.icon, + labelText: other?.labelText, + labelStyle: labelStyle?.merge(other.labelStyle) ?? other.labelStyle, + helperText: other?.helperText, + helperStyle: helperStyle?.merge(other.helperStyle) ?? other.helperStyle, + helperMaxLines: other?.helperMaxLines, + hintText: other?.hintText, + hintStyle: hintStyle?.merge(other.hintStyle) ?? other.hintStyle, + hintTextDirection: other?.hintTextDirection, + hintMaxLines: other?.hintMaxLines, + errorText: other?.errorText, + errorStyle: errorStyle?.merge(other.errorStyle) ?? other.errorStyle, + errorMaxLines: other?.errorMaxLines, + floatingLabelBehavior: other?.floatingLabelBehavior, + isCollapsed: other?.isCollapsed, + isDense: other?.isDense, + contentPadding: other?.contentPadding, + prefixIcon: other?.prefixIcon, + prefix: other?.prefix, + prefixText: other?.prefixText, + prefixIconConstraints: other?.prefixIconConstraints, + prefixStyle: prefixStyle?.merge(other.prefixStyle) ?? other.prefixStyle, + suffixIcon: other?.suffixIcon, + suffix: other?.suffix, + suffixText: other?.suffixText, + suffixStyle: suffixStyle?.merge(other.suffixStyle) ?? other.suffixStyle, + suffixIconConstraints: other?.suffixIconConstraints, + counter: other?.counter, + counterText: other?.counterText, + counterStyle: + counterStyle?.merge(other.counterStyle) ?? other.counterStyle, + filled: other?.filled, + fillColor: other?.fillColor, + focusColor: other?.focusColor, + hoverColor: other?.hoverColor, + errorBorder: other?.errorBorder, + focusedBorder: other?.focusedBorder, + focusedErrorBorder: other?.focusedErrorBorder, + disabledBorder: other?.disabledBorder, + enabledBorder: other?.enabledBorder, + border: other?.border, + enabled: other?.enabled, + semanticCounterText: other?.semanticCounterText, + alignLabelWithHint: other?.alignLabelWithHint, + ); + } +} 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 6da6a2e06..88c20a517 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -243,7 +243,7 @@ class VideoPackage { ChewieController get chewieController => _chewieController; - bool get initialized => _videoPlayerController.value.initialized; + bool get initialized => _videoPlayerController.value.isInitialized; VideoPackage( Attachment attachment, { diff --git a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart deleted file mode 100644 index 1304b90d8..000000000 --- a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../stream_chat_flutter.dart'; -import 'extension.dart'; - -class ImageActionsModal extends StatelessWidget { - final Message message; - final String userName; - final String sentAt; - final List urls; - final currentIndex; - final VoidCallback onShowMessage; - - ImageActionsModal( - {this.message, - this.userName, - this.sentAt, - this.urls, - this.currentIndex, - this.onShowMessage}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.maybePop(context), - child: _buildPage(context), - ); - } - - Widget _buildPage(context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox(height: kToolbarHeight), - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Container( - width: MediaQuery.of(context).size.width * 0.5, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), - child: Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - _buildButton( - context, - 'Reply', - StreamSvgIcon.iconCurveLineLeftUp( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.grey, - ), - () { - Navigator.pop(context, ReturnActionType.reply); - }, - ), - _buildButton( - context, - 'Show in Chat', - StreamSvgIcon.eye( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - onShowMessage, - ), - _buildButton( - context, - 'Save ${urls[currentIndex].type == 'video' ? 'Video' : 'Image'}', - StreamSvgIcon.iconSave( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.grey, - ), - () async { - var url = urls[currentIndex].imageUrl ?? - urls[currentIndex].assetUrl ?? - urls[currentIndex].thumbUrl; - - Navigator.pop(context); - - if (urls[currentIndex].type == 'video') { - await _saveVideo(url); - } else { - await _saveImage(url); - } - }, - ), - if (StreamChat.of(context).user.id == message.user.id) - _buildButton( - context, - 'Delete', - StreamSvgIcon.delete( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.accentRed, - ), - () { - final channel = StreamChannel.of(context).channel; - if (message.attachments.length > 1 || - message.text.isNotEmpty) { - final remainingAttachments = [...message.attachments] - ..removeAt(currentIndex); - channel.updateMessage(message.copyWith( - attachments: remainingAttachments, - )); - Navigator.pop(context); - Navigator.pop(context); - } else { - channel.deleteMessage(message).then((value) { - Navigator.pop(context); - Navigator.pop(context); - }); - } - }, - color: StreamChatTheme.of(context).colorTheme.accentRed, - ), - ] - .map((e) => - Align(alignment: Alignment.centerRight, child: e)) - .insertBetween( - Container( - height: 1, - color: - StreamChatTheme.of(context).colorTheme.greyWhisper, - ), - ), - ), - ), - ), - ) - ], - ); - } - - Widget _buildButton( - context, - String title, - StreamSvgIcon icon, - VoidCallback onTap, { - Color color, - }) { - return Material( - color: StreamChatTheme.of(context).colorTheme.white, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), - child: Row( - children: [ - icon, - SizedBox(width: 16), - Text( - title, - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith(color: color), - ), - ], - ), - ), - ), - ); - } - - Future _saveImage(String url) async { - var response = await Dio() - .get(url, options: Options(responseType: ResponseType.bytes)); - final result = await ImageGallerySaver.saveImage( - Uint8List.fromList(response.data), - quality: 60, - name: '${DateTime.now().millisecondsSinceEpoch}'); - return result; - } - - Future _saveVideo(String url) async { - var appDocDir = await getTemporaryDirectory(); - var savePath = - appDocDir.path + '/${DateTime.now().millisecondsSinceEpoch}.mp4'; - await Dio().download(url, savePath); - final result = await ImageGallerySaver.saveFile(savePath); - print(result); - } -} diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart index 7140e490e..1d0d7e78b 100644 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -3,9 +3,10 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; -import 'package:esys_flutter_share/esys_flutter_share.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -90,27 +91,40 @@ class _ImageFooterState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - icon: StreamSvgIcon.iconShare( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - onPressed: () async { - final attachment = - widget.mediaAttachments[widget.currentPage]; - var url = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; - var type = attachment.type == 'image' - ? 'jpg' - : url?.split('?')?.first?.split('.')?.last ?? 'jpg'; - var request = await HttpClient().getUrl(Uri.parse(url)); - var response = await request.close(); - var bytes = - await consolidateHttpClientResponseBytes(response); - await Share.file('File', 'image.$type', bytes, 'image/$type'); - }, - ), + kIsWeb + ? SizedBox() + : IconButton( + icon: StreamSvgIcon.iconShare( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () async { + final attachment = + widget.mediaAttachments[widget.currentPage]; + final url = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl; + final type = attachment.type == 'image' + ? 'jpg' + : url?.split('?')?.first?.split('.')?.last ?? 'jpg'; + final request = + await HttpClient().getUrl(Uri.parse(url)); + final response = await request.close(); + final bytes = + await consolidateHttpClientResponseBytes(response); + final tmpPath = await getTemporaryDirectory(); + final filePath = + '${tmpPath.path}/${attachment.id}.$type'; + final file = File(filePath); + await file.writeAsBytes(bytes); + await Share.shareFiles( + [filePath], + mimeTypes: [ + 'image/$type', + ], + ); + }, + ), InkWell( onTap: widget.onTitleTap, child: Container( @@ -223,13 +237,13 @@ class _ImageFooterState extends State { media = InkWell( onTap: () => widget.mediaSelectedCallBack(index), child: AspectRatio( + aspectRatio: 1.0, child: CachedNetworkImage( imageUrl: attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl, fit: BoxFit.cover, ), - aspectRatio: 1.0, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/image_header.dart index 25eab839a..bf4ec474d 100644 --- a/packages/stream_chat_flutter/lib/src/image_header.dart +++ b/packages/stream_chat_flutter/lib/src/image_header.dart @@ -3,7 +3,7 @@ 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_core/stream_chat_flutter_core.dart'; -import 'image_actions_modal.dart'; +import 'attachment_actions_modal.dart'; class ImageHeader extends StatelessWidget implements PreferredSizeWidget { /// True if this header shows the leading back button @@ -110,11 +110,8 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { builder: (context) { return StreamChannel( channel: channel, - child: ImageActionsModal( - userName: userName, - sentAt: sentAt, + child: AttachmentActionsModal( message: message, - urls: urls, currentIndex: currentIndex, onShowMessage: onShowMessage, ), diff --git a/packages/stream_chat_flutter/lib/src/media_list_view.dart b/packages/stream_chat_flutter/lib/src/media_list_view.dart index 7550e3e47..ddaf2c24a 100644 --- a/packages/stream_chat_flutter/lib/src/media_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/media_list_view.dart @@ -56,6 +56,11 @@ class _MediaListViewState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), child: InkWell( + onTap: () { + if (widget.onSelect != null) { + widget.onSelect(media); + } + }, child: Stack( children: [ AspectRatio( @@ -125,11 +130,6 @@ class _MediaListViewState extends State { ] ], ), - onTap: () { - if (widget.onSelect != null) { - widget.onSelect(media); - } - }, ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/message_action.dart b/packages/stream_chat_flutter/lib/src/message_action.dart new file mode 100644 index 000000000..5cedc962a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Class describing a message action +class MessageAction { + /// leading widget + final Widget leading; + + /// title widget + final Widget title; + + /// callback called on tap + final OnMessageTap onTap; + + /// returns a new instance of a [MessageAction] + MessageAction({ + this.leading, + this.title, + this.onTap, + }); +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 1546e66b9..883210bad 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; @@ -34,6 +35,9 @@ class MessageActionsModal extends StatefulWidget { final ShapeBorder attachmentShape; final DisplayWidget showUserAvatar; + /// List of custom actions + final List customActions; + const MessageActionsModal({ Key key, @required this.message, @@ -53,6 +57,7 @@ class MessageActionsModal extends StatefulWidget { this.messageShape, this.attachmentShape, this.reverse = false, + this.customActions = const [], }) : super(key: key); @override @@ -223,6 +228,12 @@ class _MessageActionsModalState extends State { _buildFlagButton(context), if (widget.showDeleteMessage) _buildDeleteButton(context), + ...widget.customActions.map((action) { + return _buildCustomAction( + context, + action, + ); + }) ].insertBetween( Container( height: 1, @@ -248,27 +259,69 @@ class _MessageActionsModalState extends State { ); } + InkWell _buildCustomAction( + BuildContext context, + MessageAction messageAction, + ) { + return InkWell( + onTap: () { + messageAction.onTap?.call(widget.message); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + messageAction.leading ?? Offstage(), + const SizedBox(width: 16), + messageAction.title ?? Offstage(), + ], + ), + ), + ); + } + void _showFlagDialog() async { final client = StreamChat.of(context).client; - var answer = await showConfirmationDialog(context, - title: 'Flag Message', - icon: StreamSvgIcon.flag( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, - ), - question: - 'Do you want to send a copy of this message to a\nmoderator for further investigation?', - okText: 'FLAG', - cancelText: 'CANCEL'); + final answer = await showConfirmationDialog( + context, + title: 'Flag Message', + icon: StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + question: + 'Do you want to send a copy of this message to a\nmoderator for further investigation?', + okText: 'FLAG', + cancelText: 'CANCEL', + ); - if (answer) { + final theme = StreamChatTheme.of(context); + if (answer == true) { try { await client.flagMessage(widget.message.id); - _showDismissAlert(); + await showInfoDialog( + context, + icon: StreamSvgIcon.flag( + color: theme.colorTheme.accentRed, + size: 24.0, + ), + details: 'The message has been reported to a moderator.', + title: 'Message flagged', + okText: 'OK', + ); } catch (err) { if (json.decode(err?.body ?? {})['code'] == 4) { - _showDismissAlert(); + await showInfoDialog( + context, + icon: StreamSvgIcon.flag( + color: theme.colorTheme.accentRed, + size: 24.0, + ), + details: 'The message has been reported to a moderator.', + title: 'Message flagged', + okText: 'OK', + ); } else { _showErrorAlert(); } @@ -306,133 +359,16 @@ class _MessageActionsModalState extends State { } } - void _showDismissAlert() { - showModalBottomSheet( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - )), - builder: (context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 26.0, - ), - StreamSvgIcon.flag( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, - ), - SizedBox( - height: 26.0, - ), - Text( - 'Message flagged', - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox( - height: 7.0, - ), - Text('The message has been reported to a moderator.'), - SizedBox( - height: 36.0, - ), - Container( - color: - StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), - height: 1.0, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - child: Text( - 'OK', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ], - ); - }, - ); - } - void _showErrorAlert() { - showModalBottomSheet( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - )), - builder: (context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 26.0, - ), - StreamSvgIcon.error( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, - ), - SizedBox( - height: 26.0, - ), - Text( - 'Something went wrong', - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox( - height: 7.0, - ), - Text('The operation couldn\'t be completed.'), - SizedBox( - height: 36.0, - ), - Container( - color: - StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), - height: 1.0, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - child: Text( - 'OK', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ], - ); - }, + showInfoDialog( + context, + icon: StreamSvgIcon.error( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + details: 'The operation couldn\'t be completed.', + title: 'Something went wrong', + okText: 'OK', ); } @@ -637,21 +573,16 @@ class _MessageActionsModalState extends State { ], ), ), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: widget.editMessageInputBuilder != null - ? widget.editMessageInputBuilder(context, widget.message) - : MessageInput( - editMessage: widget.message, - preMessageSending: (m) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - return m; - }, - ), - ), + widget.editMessageInputBuilder != null + ? widget.editMessageInputBuilder(context, widget.message) + : MessageInput( + editMessage: widget.message, + preMessageSending: (m) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + return m; + }, + ), ], ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 21424c2e6..21dda0f4e 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -11,6 +11,7 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; @@ -42,6 +43,15 @@ enum DefaultAttachmentTypes { file, } +/// Available locations for the sendMessage button relative to the textField +enum SendButtonLocation { + /// inside the textField + inside, + + /// outside the textField + outside, +} + const _kMinMediaPickerSize = 360.0; const _kMaxAttachmentSize = 20971520; // 20MB in Bytes @@ -107,6 +117,11 @@ class MessageInput extends StatefulWidget { this.focusNode, this.quotedMessage, this.onQuotedMessageCleared, + this.sendButtonLocation = SendButtonLocation.outside, + this.autofocus = false, + this.hideSendAsDm = false, + this.idleSendButton, + this.activeSendButton, }) : super(key: key); /// Message to edit @@ -134,6 +149,9 @@ class MessageInput extends StatefulWidget { /// If true the attachments button will not be displayed final bool disableAttachments; + /// Hide send as dm checkbox + final bool hideSendAsDm; + /// The text controller of the TextField final TextEditingController textEditingController; @@ -155,6 +173,18 @@ class MessageInput extends StatefulWidget { /// final VoidCallback onQuotedMessageCleared; + /// The location of the send button + final SendButtonLocation sendButtonLocation; + + /// Autofocus property passed to the TextField + final bool autofocus; + + /// Send button widget in an idle state + final Widget idleSendButton; + + /// Send button widget in an active state + final Widget activeSendButton; + @override MessageInputState createState() => MessageInputState(); @@ -281,7 +311,7 @@ class MessageInputState extends State { padding: const EdgeInsets.all(8.0), child: _buildTextField(context), ), - if (widget.parentMessage != null) + if (widget.parentMessage != null && !widget.hideSendAsDm) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildDmCheckbox(), @@ -304,15 +334,16 @@ class MessageInputState extends State { Flex _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ if (!_commandEnabled) _buildExpandActionsButton(), if (widget.actionsLocation == ActionsLocation.left) ...widget.actions ?? [], _buildTextInput(context), - _animateSendButton(context), if (widget.actionsLocation == ActionsLocation.right) ...widget.actions ?? [], + if (widget.sendButtonLocation == SendButtonLocation.outside) + _animateSendButton(context), ], ); } @@ -384,14 +415,20 @@ class MessageInputState extends State { } Widget _animateSendButton(BuildContext context) { + final sendButton = widget.activeSendButton != null + ? InkWell( + onTap: sendMessage, + child: widget.activeSendButton, + ) + : _buildSendButton(context); return Padding( padding: const EdgeInsets.all(8.0), child: AnimatedCrossFade( crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: _buildSendButton(context), - secondChild: _buildIdleSendButton(context), + firstChild: sendButton, + secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), duration: StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration, alignment: Alignment.center, @@ -447,101 +484,139 @@ class MessageInputState extends State { child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.0), - border: Border.all(color: theme.colorTheme.greyGainsboro), + borderRadius: theme.messageInputTheme.borderRadius, + gradient: _focusNode.hasFocus + ? theme.messageInputTheme.activeBorderGradient + : theme.messageInputTheme.idleBorderGradient, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: TextField( - key: Key('messageInputText'), - enabled: _inputEnabled, - minLines: null, - maxLines: null, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: textEditingController, - focusNode: _focusNode, - style: theme.textTheme.body, - autofocus: false, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - isDense: true, - hintText: _getHint(), - hintStyle: theme.textTheme.body.copyWith( - color: theme.colorTheme.grey, + child: Padding( + padding: const EdgeInsets.all(1.5), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: theme.messageInputTheme.borderRadius, + color: theme.messageInputTheme.inputBackground, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: Key('messageInputText'), + enabled: _inputEnabled, + minLines: null, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: theme.messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(), + textCapitalization: TextCapitalization.sentences, ), - border: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - errorBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), - prefixIconConstraints: BoxConstraints.tight(Size(78, 24)), - suffixIconConstraints: BoxConstraints.tight(Size(40, 40)), - prefixIcon: _commandEnabled - ? Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.accentBlue, - ), - margin: const EdgeInsets.only(right: 4, left: 8), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - StreamSvgIcon.lightning( - color: Colors.white, - size: 16.0, - ), - Text( - _chosenCommand?.name?.toUpperCase() ?? '', - style: StreamChatTheme.of(context) - .textTheme - .footnoteBold - .copyWith( - color: Colors.white, - ), - ), - ], - ), - ) - : null, - suffixIcon: _commandEnabled - ? IconButton( - icon: StreamSvgIcon.closeSmall(), - splashRadius: 24, - padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - setState(() => _commandEnabled = false); - }, - ) - : null, - ), - textCapitalization: TextCapitalization.sentences, - ), - ) - ], + ) + ], + ), + ), ), ), ), ); } + InputDecoration _getInputDecoration() { + final theme = StreamChatTheme.of(context); + final passedDecoration = theme.messageInputTheme.inputDecoration; + return InputDecoration( + isDense: true, + hintText: _getHint(), + hintStyle: theme.messageInputTheme.inputTextStyle.copyWith( + color: theme.colorTheme.grey, + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), + prefixIconConstraints: BoxConstraints.tight(Size(78, 24)), + suffixIconConstraints: BoxConstraints.tight(Size(40, 40)), + prefixIcon: _commandEnabled + ? Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.accentBlue, + ), + margin: const EdgeInsets.only(right: 4, left: 8), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.lightning( + color: Colors.white, + size: 16.0, + ), + Text( + _chosenCommand?.name?.toUpperCase() ?? '', + style: StreamChatTheme.of(context) + .textTheme + .footnoteBold + .copyWith( + color: Colors.white, + ), + ), + ], + ), + ) + : null, + suffixIcon: Row( + children: [ + if (_commandEnabled) + IconButton( + icon: StreamSvgIcon.closeSmall(), + splashRadius: 24, + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + onPressed: () { + setState(() => _commandEnabled = false); + }, + ), + if (widget.sendButtonLocation == SendButtonLocation.inside) + _animateSendButton(context), + ], + ), + ).merge(passedDecoration); + } + Timer _debounce; void _onChanged(BuildContext context, String s) { @@ -621,7 +696,9 @@ class MessageInputState extends State { .last .contains('@')) { _mentionsOverlay = _buildMentionsOverlayEntry(); - Overlay.of(context).insert(_mentionsOverlay); + if (_mentionsOverlay != null) { + Overlay.of(context).insert(_mentionsOverlay); + } } } @@ -646,7 +723,9 @@ class MessageInputState extends State { _commandsOverlay = null; } else { _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); + if (_commandsOverlay != null) { + Overlay.of(context).insert(_commandsOverlay); + } } } } @@ -661,6 +740,10 @@ class MessageInputState extends State { ?.toList() ?? []; + if (commands.isEmpty) { + return null; + } + RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; @@ -833,6 +916,7 @@ class MessageInputState extends State { child: Material( color: StreamChatTheme.of(context).colorTheme.whiteSmoke, child: Column( + mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.start, @@ -930,7 +1014,19 @@ class MessageInputState extends State { color: StreamChatTheme.of(context).colorTheme.white, borderRadius: BorderRadius.circular(8.0), ), - child: _buildPickerSection(), + child: _PickerWidget( + filePickerIndex: _filePickerIndex, + containsFile: _attachmentContainsFile, + selectedMedias: _attachments.keys.toList(), + onAddMoreFilesClick: pickFile, + onMediaSelected: (media) { + if (_attachments.containsKey(media.id)) { + setState(() => _attachments.remove(media.id)); + } else { + _addAttachment(media); + } + }, + ), ), ), ], @@ -939,109 +1035,6 @@ class MessageInputState extends State { ); } - Widget _buildPickerSection() { - final _attachmentContainsFile = _attachments.values.any((it) { - return it.type == 'file'; - }); - - switch (_filePickerIndex) { - case 0: - return FutureBuilder( - future: PhotoManager.requestPermission(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Center( - child: CircularProgressIndicator(), - ); - } - - if (snapshot.data) { - if (_attachmentContainsFile) { - return GestureDetector( - onTap: () { - pickFile(DefaultAttachmentTypes.file); - }, - child: Container( - constraints: BoxConstraints.expand(), - color: StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Text( - 'Add more files', - style: TextStyle( - color: - StreamChatTheme.of(context).colorTheme.accentBlue, - fontWeight: FontWeight.bold, - ), - ), - alignment: Alignment.center, - ), - ); - } - return MediaListView( - selectedIds: _attachments.keys.toList(), - onSelect: (media) async { - if (!_attachments.containsKey(media.id)) { - _addAttachment(media); - } else { - setState(() => _attachments.remove(media.id)); - } - }, - ); - } - - return InkWell( - onTap: () async { - PhotoManager.openSetting(); - }, - child: Container( - color: StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SvgPicture.asset( - 'svgs/icon_picture_empty_state.svg', - package: 'stream_chat_flutter', - height: 140, - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, - ), - Text( - 'Please enable access to your photos \nand videos so you can share them with friends.', - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .grey), - textAlign: TextAlign.center, - ), - SizedBox(height: 6.0), - Center( - child: Text( - 'Allow access to your gallery', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - ), - ), - ), - ], - ), - ), - ); - }); - break; - default: - return SizedBox(); - } - } - void _addAttachment(AssetEntity medium) async { final mediaFile = await medium.originFile.timeout( Duration(seconds: 5), @@ -1090,81 +1083,81 @@ class MessageInputState extends State { switch (iconType) { case 'giphy': return CircleAvatar( + radius: 12, child: StreamSvgIcon.giphyIcon( size: 24.0, ), - radius: 12, ); break; case 'ban': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.iconUserDelete( size: 16.0, color: Colors.white, ), - radius: 12, ); break; case 'flag': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.flag( size: 14.0, color: Colors.white, ), - radius: 12, ); break; case 'imgur': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: ClipOval( child: StreamSvgIcon.imgur( size: 24.0, ), ), - radius: 12, ); break; case 'mute': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.mute( size: 16.0, color: Colors.white, ), - radius: 12, ); break; case 'unban': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.userAdd( size: 16.0, color: Colors.white, ), - radius: 12, ); break; case 'unmute': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.volumeUp( size: 16.0, color: Colors.white, ), - radius: 12, ); break; default: return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + radius: 12, child: StreamSvgIcon.lightning( size: 16.0, color: Colors.white, ), - radius: 12, ); break; } @@ -1191,6 +1184,10 @@ class MessageInputState extends State { })?.toList() ?? []; + if (members.isEmpty) { + return null; + } + RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; @@ -1646,11 +1643,15 @@ class MessageInputState extends State { return getFileTypeImage(attachment.extraData['other']); }, progressIndicatorBuilder: (context, _, progress) { - return Center( - child: Container( - width: 20.0, - height: 20.0, - child: const CircularProgressIndicator(), + return Shimmer.fromColors( + baseColor: + StreamChatTheme.of(context).colorTheme.greyGainsboro, + highlightColor: + StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', ), ); }, @@ -1676,8 +1677,8 @@ class MessageInputState extends State { ); default: return Container( - child: Icon(Icons.insert_drive_file), color: Colors.black26, + child: Icon(Icons.insert_drive_file), ); } } @@ -1903,14 +1904,12 @@ class MessageInputState extends State { if (file == null) return; - final mimeType = file.path.split('/').last.mimeType; + final mimeType = file.name?.mimeType; final extraDataMap = {}; - if (camera) { - if (mimeType.type == 'video' || mimeType.type == 'image') { - attachmentType = mimeType.type; - } + if (mimeType.type == 'video' || mimeType.type == 'image') { + attachmentType = mimeType.type; } else { attachmentType = 'file'; } @@ -2013,9 +2012,10 @@ class MessageInputState extends State { return; } + final shouldUnfocus = _commandEnabled; + if (_commandEnabled) { text = '/${_chosenCommand.name} ' + text; - FocusScope.of(context).unfocus(); } final attachments = [..._attachments.values]; @@ -2082,7 +2082,14 @@ class MessageInputState extends State { sendingFuture = channel.updateMessage(message); } + if (!shouldUnfocus) { + FocusScope.of(context).requestFocus(_focusNode); + } + return sendingFuture.then((resp) { + if (resp.message?.type == 'error') { + _parseExistingMessage(message); + } if (widget.onMessageSent != null) { widget.onMessageSent(resp.message); } @@ -2140,6 +2147,9 @@ class MessageInputState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, child: Text( 'OK', style: StreamChatTheme.of(context) @@ -2150,9 +2160,6 @@ class MessageInputState extends State { .colorTheme .accentBlue), ), - onPressed: () { - Navigator.of(context).pop(); - }, ), ], ), @@ -2240,3 +2247,114 @@ class Tuple2 { @override int get hashCode => item1.hashCode ^ item2.hashCode; } + +class _PickerWidget extends StatefulWidget { + final int filePickerIndex; + final bool containsFile; + final List selectedMedias; + final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; + final void Function(AssetEntity) onMediaSelected; + + const _PickerWidget({ + Key key, + @required this.filePickerIndex, + @required this.containsFile, + @required this.selectedMedias, + @required this.onAddMoreFilesClick, + @required this.onMediaSelected, + }) : super(key: key); + + @override + __PickerWidgetState createState() => __PickerWidgetState(); +} + +class __PickerWidgetState extends State<_PickerWidget> { + Future requestPermission; + + @override + void initState() { + super.initState(); + requestPermission = PhotoManager.requestPermission(); + } + + @override + Widget build(BuildContext context) { + if (widget.filePickerIndex != 0) { + return Offstage(); + } + return FutureBuilder( + future: requestPermission, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.data) { + if (widget.containsFile) { + return GestureDetector( + onTap: () { + widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); + }, + child: Container( + constraints: BoxConstraints.expand(), + color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + alignment: Alignment.center, + child: Text( + 'Add more files', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + return MediaListView( + selectedIds: widget.selectedMedias, + onSelect: widget.onMediaSelected, + ); + } + + return InkWell( + onTap: () async { + PhotoManager.openSetting(); + }, + child: Container( + color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ), + Text( + 'Please enable access to your photos \nand videos so you can share them with friends.', + style: StreamChatTheme.of(context).textTheme.body.copyWith( + color: StreamChatTheme.of(context).colorTheme.grey), + textAlign: TextAlign.center, + ), + SizedBox(height: 6.0), + Center( + child: Text( + 'Allow access to your gallery', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + ), + ], + ), + ), + ); + }); + } +} 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 505c4e0fa..e6c1e93b0 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -28,10 +28,15 @@ typedef ParentMessageBuilder = Widget Function( BuildContext, Message, ); +typedef SystemMessageBuilder = Widget Function( + BuildContext, + Message, +); typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); typedef ThreadTapCallback = void Function(Message, Widget); typedef OnMessageSwiped = void Function(Message); +typedef OnMessageTap = void Function(Message); typedef ReplyTapCallback = void Function(Message); class MessageDetails { @@ -128,14 +133,22 @@ class MessageListView extends StatefulWidget { this.showConnectionStateTile = false, this.loadingBuilder, this.emptyBuilder, + this.systemMessageBuilder, this.messageListBuilder, this.errorWidgetBuilder, + this.messageFilter, this.customAttachmentBuilders, + this.onMessageTap, + this.onSystemMessageTap, + this.onAttachmentTap, }) : super(key: key); /// Function used to build a custom message widget final MessageBuilder messageBuilder; + /// Function used to build a custom system message widget + final SystemMessageBuilder systemMessageBuilder; + /// Function used to build a custom parent message widget final ParentMessageBuilder parentMessageBuilder; @@ -204,10 +217,22 @@ class MessageListView extends StatefulWidget { /// of a connection failure. final ErrorBuilder errorWidgetBuilder; + /// Predicate used to filter messages + final bool Function(Message) messageFilter; + /// Attachment builders for the default message widget /// Please change this in the [MessageWidget] if you are using a custom implementation final Map customAttachmentBuilders; + /// Called when any message is tapped except a system message (use [onSystemMessageTap] instead) + final OnMessageTap onMessageTap; + + /// Called when system message is tapped + final OnMessageTap onSystemMessageTap; + + // Customize onTap on attachment + final void Function(Message message, Attachment attachment) onAttachmentTap; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -265,6 +290,7 @@ class _MessageListViewState extends State { @override Widget build(BuildContext context) { return MessageListCore( + messageFilter: widget.messageFilter, loadingBuilder: widget.loadingBuilder ?? (context) { return Center( @@ -356,6 +382,9 @@ class _MessageListViewState extends State { childAnchor: Alignment.topCenter, message: statusString, child: LazyLoadScrollView( + onPageScrollStart: () { + FocusScope.of(context).unfocus(); + }, onStartOfPage: () async { _inBetweenList = false; if (!_upToDate) { @@ -408,7 +437,7 @@ class _MessageListViewState extends State { style: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme - .lastMessageAt, + .subtitle, ), ), ); @@ -595,9 +624,6 @@ class _MessageListViewState extends State { children: [ FloatingActionButton( backgroundColor: StreamChatTheme.of(context).colorTheme.white, - child: StreamSvgIcon.down( - color: StreamChatTheme.of(context).colorTheme.black, - ), onPressed: () { if (unreadCount > 0) { streamChannel.channel.markRead(); @@ -615,6 +641,9 @@ class _MessageListViewState extends State { ); } }, + child: StreamSvgIcon.down( + color: StreamChatTheme.of(context).colorTheme.black, + ), ), if (showUnreadCount) Positioned( @@ -804,6 +833,12 @@ class _MessageListViewState extends State { } }, customAttachmentBuilders: widget.customAttachmentBuilders, + onMessageTap: (message) { + if (widget.onMessageTap != null) { + widget.onMessageTap(message); + } + FocusScope.of(context).unfocus(); + }, ); } @@ -812,11 +847,19 @@ class _MessageListViewState extends State { List messages, int index, ) { - if (message.type == 'system' && message.text?.isNotEmpty == true) { - return SystemMessage( - key: ValueKey('MESSAGE-${message.id}'), - message: message, - ); + if ((message.type == 'system' || message.type == 'error') && + message.text?.isNotEmpty == true) { + return widget.systemMessageBuilder?.call(context, message) ?? + SystemMessage( + key: ValueKey('MESSAGE-${message.id}'), + message: message, + onMessageTap: (message) { + if (widget.onSystemMessageTap != null) { + widget.onSystemMessageTap(message); + } + FocusScope.of(context).unfocus(); + }, + ); } final userId = StreamChat.of(context).user.id; @@ -965,6 +1008,13 @@ class _MessageListViewState extends State { } }, customAttachmentBuilders: widget.customAttachmentBuilders, + onMessageTap: (message) { + if (widget.onMessageTap != null) { + widget.onMessageTap(message); + } + FocusScope.of(context).unfocus(); + }, + onAttachmentTap: widget.onAttachmentTap, ); if (!message.isDeleted && @@ -995,10 +1045,6 @@ class _MessageListViewState extends State { end: colorTheme.white.withOpacity(0), ), duration: const Duration(seconds: 3), - child: Padding( - padding: const EdgeInsets.only(top: 4.0), - child: child, - ), onEnd: () => initialMessageHighlightComplete = true, builder: (_, color, child) { return Container( @@ -1006,6 +1052,10 @@ class _MessageListViewState extends State { child: child, ); }, + child: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: child, + ), ); } return child; diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index cc587786d..1d8dbae76 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -237,6 +237,9 @@ class MessageReactionsModal extends StatelessWidget { borderRadius: BorderRadius.circular(32), ), Positioned( + bottom: 6, + left: isCurrentUser ? -3 : null, + right: isCurrentUser ? -3 : null, child: Align( alignment: reverse ? Alignment.centerRight : Alignment.centerLeft, @@ -250,9 +253,6 @@ class MessageReactionsModal extends StatelessWidget { highlightOwnReactions: false, ), ), - bottom: 6, - left: isCurrentUser ? -3 : null, - right: isCurrentUser ? -3 : null, ), ], ), 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 165ed47b4..316e27b39 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 @@ -157,53 +157,14 @@ class _MessageSearchListViewState extends State { errorBuilder: widget.errorBuilder ?? (BuildContext context, dynamic error) { if (error is Error) { - print((error).stackTrace); - } - - var message = error.toString(); - if (error is DioError) { - if (error.type == DioErrorType.RESPONSE) { - message = error.message; - } else { - message = 'Check your connection and retry'; - } + print(error.stackTrace); } return InfoTile( showMessage: widget.showErrorTile, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: 'An error occurred.', - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only(right: 2.0), - child: Icon(Icons.error_outline), - ), - ), - TextSpan(text: 'Error loading messages'), - ], - ), - style: Theme.of(context).textTheme.headline6, - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Text(message), - ), - ElevatedButton( - onPressed: () { - _messageSearchListController.loadData(); - }, - child: Text('Retry'), - ), - ], - ), - ), + child: Container(), ); }, loadingBuilder: widget.loadingBuilder ?? diff --git a/packages/stream_chat_flutter/lib/src/message_text.dart b/packages/stream_chat_flutter/lib/src/message_text.dart index 7557a8770..3afa60f90 100644 --- a/packages/stream_chat_flutter/lib/src/message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_text.dart @@ -21,7 +21,8 @@ class MessageText extends StatelessWidget { @override Widget build(BuildContext context) { - final text = _replaceMentions(message.text); + final text = _replaceMentions(message.text).replaceAll('\n', '\\\n'); + return MarkdownBody( data: text, onTapLink: ( diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 91c535666..7f2c83ebb 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; @@ -143,6 +144,15 @@ class MessageWidget extends StatefulWidget { /// Function called when quotedMessage is tapped final OnQuotedMessageTap onQuotedMessageTap; + /// Function called when message is tapped + final void Function(Message) onMessageTap; + + /// List of custom actions shown on message long tap + final List customActions; + + // Customize onTap on attachment + final void Function(Message message, Attachment attachment) onAttachmentTap; + /// MessageWidget({ Key key, @@ -157,6 +167,7 @@ class MessageWidget extends StatefulWidget { this.borderRadiusGeometry, this.attachmentBorderRadiusGeometry, this.onMentionTap, + this.onMessageTap, this.showReactionPickerIndicator = false, this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = true, @@ -191,6 +202,8 @@ class MessageWidget extends StatefulWidget { this.attachmentPadding = EdgeInsets.zero, this.allRead = false, this.onQuotedMessageTap, + this.customActions = const [], + this.onAttachmentTap, }) : attachmentBuilders = { 'image': (context, message, attachment) { return ImageAttachment( @@ -203,6 +216,11 @@ class MessageWidget extends StatefulWidget { ), onShowMessage: onShowMessage, onReturnAction: onReturnAction, + onAttachmentTap: onAttachmentTap != null + ? () { + onAttachmentTap?.call(message, attachment); + } + : null, ); }, 'video': (context, message, attachment) { @@ -216,6 +234,11 @@ class MessageWidget extends StatefulWidget { message: message, onShowMessage: onShowMessage, onReturnAction: onReturnAction, + onAttachmentTap: onAttachmentTap != null + ? () { + onAttachmentTap?.call(message, attachment); + } + : null, ); }, 'giphy': (context, message, attachment) { @@ -311,6 +334,9 @@ class _MessageWidgetState extends State type: MaterialType.transparency, child: Portal( child: InkWell( + onTap: () { + widget.onMessageTap(widget.message); + }, onLongPress: widget.message.isDeleted && !isFailedState ? null : () => onLongPress(context), @@ -351,9 +377,9 @@ class _MessageWidgetState extends State portal: Container( transform: Matrix4.translationValues(-12, 0, 0), - child: _buildReactionIndicator(context), constraints: BoxConstraints(maxWidth: 22 * 6.0), + child: _buildReactionIndicator(context), ), portalAnchor: Alignment(-1.0, -1.0), childAnchor: Alignment(1, -1.0), @@ -745,6 +771,7 @@ class _MessageWidgetState extends State !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, + customActions: widget.customActions, ), ); }); @@ -951,6 +978,7 @@ class _MessageWidgetState extends State user: widget.message.user, onTap: widget.onUserAvatarTap, constraints: widget.messageTheme.avatarTheme.constraints, + borderRadius: widget.messageTheme.avatarTheme.borderRadius, showOnlineStatus: false, ), ), diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 507414199..4d2b2e387 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -55,7 +55,7 @@ class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { return Container( height: widget.size.height, width: widget.size.width, - child: _controller.value.initialized + child: _controller.value.isInitialized ? VideoPlayer(_controller) : CircularProgressIndicator()); } diff --git a/packages/stream_chat_flutter/lib/src/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reaction_picker.dart index b0aa51cea..a33e7b528 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_picker.dart @@ -94,6 +94,19 @@ class _ReactionPickerState extends State height: 24, width: 24, ), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction( + context, + widget.message.ownReactions[ownReactionIndex], + ); + } else { + sendReaction( + context, + reactionIcon.type, + ); + } + }, child: AnimatedBuilder( animation: animations[index], builder: (context, val) { @@ -121,19 +134,6 @@ class _ReactionPickerState extends State ), ); }), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions[ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, ), ); }) diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index f093a81a3..66b49d883 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_app_badger/flutter_app_badger.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -93,9 +92,9 @@ class StreamChatState extends State { ), child: StreamChatCore( client: client, - child: widget.child, onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, + child: widget.child, ), ); }, @@ -121,12 +120,5 @@ class StreamChatState extends State { @override void initState() { super.initState(); - client.state?.totalUnreadCountStream?.listen((count) { - if (count > 0) { - FlutterAppBadger.updateBadgeCount(count); - } else { - FlutterAppBadger.removeBadge(); - } - }); } } 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 aa4f51f0d..eafa12c54 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Inherited widget providing the [StreamChatThemeData] to the widget tree @@ -45,12 +46,15 @@ class StreamChatThemeData { /// The text themes used in the widgets final TextTheme textTheme; - /// The text themes used in the widgets + /// The color themes used in the widgets final ColorTheme colorTheme; /// Theme of the [ChannelPreview] final ChannelPreviewTheme channelPreviewTheme; + /// Theme of the [ChannelListHeader] + final ChannelListHeaderTheme channelListHeaderTheme; + /// Theme of the chat widgets dedicated to a channel final ChannelTheme channelTheme; @@ -60,7 +64,7 @@ class StreamChatThemeData { /// Theme of other users messages final MessageTheme otherMessageTheme; - /// Theme of other users messages + /// Theme dedicated to the [MessageInput] widget final MessageInputTheme messageInputTheme; /// The widget that will be built when the channel image is unavailable @@ -79,6 +83,7 @@ class StreamChatThemeData { const StreamChatThemeData({ this.textTheme, this.colorTheme, + this.channelListHeaderTheme, this.channelPreviewTheme, this.channelTheme, this.otherMessageTheme, @@ -98,9 +103,7 @@ class StreamChatThemeData { accentBlue: theme.accentColor, ), defaultTheme.textTheme, - ).copyWith( - // primaryIconTheme: theme.primaryIconTheme, - ); + ); return defaultTheme.merge(customizedTheme) ?? customizedTheme; } @@ -116,9 +119,12 @@ class StreamChatThemeData { Widget Function(BuildContext, Channel) defaultChannelImage, Widget Function(BuildContext, User) defaultUserImage, IconThemeData primaryIconTheme, + ChannelListHeaderTheme channelListHeaderTheme, List reactionIcons, }) => StreamChatThemeData( + channelListHeaderTheme: + channelListHeaderTheme ?? this.channelListHeaderTheme, textTheme: textTheme ?? this.textTheme, colorTheme: colorTheme ?? this.colorTheme, primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, @@ -135,6 +141,9 @@ class StreamChatThemeData { StreamChatThemeData merge(StreamChatThemeData other) { if (other == null) return this; return copyWith( + channelListHeaderTheme: + channelListHeaderTheme?.merge(other.channelListHeaderTheme) ?? + other.channelListHeaderTheme, textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme, colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme, primaryIconTheme: other.primaryIconTheme, @@ -189,6 +198,17 @@ class StreamChatThemeData { color: colorTheme.black.withOpacity(.5), ), indicatorIconSize: 16.0), + channelListHeaderTheme: ChannelListHeaderTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + color: colorTheme.white, + title: textTheme.headlineBold, + ), channelTheme: ChannelTheme( channelHeaderTheme: ChannelHeaderTheme( avatarTheme: AvatarTheme( @@ -200,9 +220,8 @@ class StreamChatThemeData { ), color: colorTheme.white, title: textTheme.headlineBold, - lastMessageAt: TextStyle( - fontSize: 11, - color: colorTheme.black.withOpacity(.5), + subtitle: textTheme.footnote.copyWith( + color: Color(0xff7A7A7A), ), ), ), @@ -249,12 +268,26 @@ class StreamChatThemeData { ), ), messageInputTheme: MessageInputTheme( + borderRadius: BorderRadius.circular(20), sendAnimationDuration: Duration(milliseconds: 300), actionButtonColor: colorTheme.accentBlue, actionButtonIdleColor: colorTheme.grey, sendButtonColor: colorTheme.accentBlue, sendButtonIdleColor: colorTheme.greyGainsboro, inputBackground: colorTheme.white, + inputTextStyle: textTheme.body, + idleBorderGradient: LinearGradient( + colors: [ + colorTheme.greyGainsboro, + colorTheme.greyGainsboro, + ], + ), + activeBorderGradient: LinearGradient( + colors: [ + colorTheme.greyGainsboro, + colorTheme.greyGainsboro, + ], + ), ), reactionIcons: [ ReactionIcon( @@ -809,26 +842,26 @@ class ChannelPreviewTheme { class ChannelHeaderTheme { final TextStyle title; - final TextStyle lastMessageAt; + final TextStyle subtitle; final AvatarTheme avatarTheme; final Color color; const ChannelHeaderTheme({ this.title, - this.lastMessageAt, + this.subtitle, this.avatarTheme, this.color, }); ChannelHeaderTheme copyWith({ TextStyle title, - TextStyle lastMessageAt, + TextStyle subtitle, AvatarTheme avatarTheme, Color color, }) => ChannelHeaderTheme( title: title ?? this.title, - lastMessageAt: lastMessageAt ?? this.lastMessageAt, + subtitle: subtitle ?? this.subtitle, avatarTheme: avatarTheme ?? this.avatarTheme, color: color ?? this.color, ); @@ -837,8 +870,48 @@ class ChannelHeaderTheme { if (other == null) return this; return copyWith( title: title?.merge(other.title) ?? other.title, - lastMessageAt: - lastMessageAt?.merge(other.lastMessageAt) ?? other.lastMessageAt, + subtitle: subtitle?.merge(other.subtitle) ?? other.subtitle, + avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + color: other.color, + ); + } +} + +/// Theme dedicated to the [ChannelListHeader] +class ChannelListHeaderTheme { + /// Style of the title text + final TextStyle title; + + /// Theme dedicated to the userAvatar + final AvatarTheme avatarTheme; + + /// Background color of the appbar + final Color color; + + /// Returns a new [ChannelListHeaderTheme] + const ChannelListHeaderTheme({ + this.title, + this.avatarTheme, + this.color, + }); + + /// Returns a new [ChannelListHeaderTheme] replacing some of its properties + ChannelListHeaderTheme copyWith({ + TextStyle title, + AvatarTheme avatarTheme, + Color color, + }) => + ChannelListHeaderTheme( + title: title ?? this.title, + avatarTheme: avatarTheme ?? this.avatarTheme, + color: color ?? this.color, + ); + + /// Merges [this] [ChannelListHeaderTheme] with the [other] + ChannelListHeaderTheme merge(ChannelListHeaderTheme other) { + if (other == null) return this; + return copyWith( + title: title?.merge(other.title) ?? other.title, avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, color: other.color, ); @@ -865,6 +938,21 @@ class MessageInputTheme { /// Background color of [MessageInput] final Color inputBackground; + /// TextStyle of [MessageInput] + final TextStyle inputTextStyle; + + /// InputDecoration of [MessageInput] + final InputDecoration inputDecoration; + + /// Border gradient when the [MessageInput] is not focused + final Gradient idleBorderGradient; + + /// Border gradient when the [MessageInput] is focused + final Gradient activeBorderGradient; + + /// Border radius of [MessageInput] + final BorderRadius borderRadius; + /// Returns a new [MessageInputTheme] const MessageInputTheme({ this.sendAnimationDuration, @@ -873,6 +961,11 @@ class MessageInputTheme { this.actionButtonIdleColor, this.sendButtonIdleColor, this.inputBackground, + this.inputTextStyle, + this.inputDecoration, + this.activeBorderGradient, + this.idleBorderGradient, + this.borderRadius, }); /// Returns a new [MessageInputTheme] replacing some of its properties @@ -883,6 +976,11 @@ class MessageInputTheme { Color sendButtonColor, Color actionButtonIdleColor, Color sendButtonIdleColor, + TextStyle inputTextStyle, + InputDecoration inputDecoration, + Gradient activeBorderGradient, + Gradient idleBorderGradient, + BorderRadius borderRadius, }) => MessageInputTheme( sendAnimationDuration: @@ -892,7 +990,12 @@ class MessageInputTheme { sendButtonColor: sendButtonColor ?? this.sendButtonColor, actionButtonIdleColor: actionButtonIdleColor ?? this.actionButtonIdleColor, + inputTextStyle: inputTextStyle ?? this.inputTextStyle, sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, + inputDecoration: inputDecoration ?? this.inputDecoration, + activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, + idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, + borderRadius: borderRadius ?? this.borderRadius, ); /// Merges [this] [MessageInputTheme] with the [other] @@ -905,6 +1008,12 @@ class MessageInputTheme { actionButtonIdleColor: other.actionButtonIdleColor, sendButtonColor: other.sendButtonColor, sendButtonIdleColor: other.sendButtonIdleColor, + inputTextStyle: other.inputTextStyle, + inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? + other.inputDecoration, + activeBorderGradient: other.activeBorderGradient, + idleBorderGradient: other.idleBorderGradient, + borderRadius: other.borderRadius, ); } } diff --git a/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart index bf3e8e97a..35e989607 100644 --- a/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart +++ b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart @@ -13,7 +13,6 @@ class StreamNeumorphicButton extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - child: child, margin: EdgeInsets.all(8.0), height: 40, width: 40, @@ -35,6 +34,7 @@ class StreamNeumorphicButton extends StatelessWidget { ), ], ), + child: child, ); } } diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index fa5d9dc4a..374b9ba16 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -18,24 +18,15 @@ class StreamSvgIcon extends StatelessWidget { @override Widget build(BuildContext context) { final key = Key('StreamSvgIcon-$assetName'); - return kIsWeb - ? Image.network( - 'packages/stream_chat_flutter/svgs/$assetName', - width: width, - height: height, - key: key, - color: color, - alignment: Alignment.center, - ) - : SvgPicture.asset( - 'lib/svgs/$assetName', - package: 'stream_chat_flutter', - key: key, - width: width, - height: height, - color: color, - alignment: Alignment.center, - ); + return SvgPicture.asset( + 'lib/svgs/$assetName', + package: 'stream_chat_flutter', + key: key, + width: width, + height: height, + color: color, + alignment: Alignment.center, + ); } factory StreamSvgIcon.settings({ diff --git a/packages/stream_chat_flutter/lib/src/system_message.dart b/packages/stream_chat_flutter/lib/src/system_message.dart index 7b4dc243d..ac3176ce6 100644 --- a/packages/stream_chat_flutter/lib/src/system_message.dart +++ b/packages/stream_chat_flutter/lib/src/system_message.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// It shows a date divider depending on the date difference @@ -18,37 +17,7 @@ class SystemMessage extends StatelessWidget { @override Widget build(BuildContext context) { - final divider = Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Divider(), - ), - ); - - final createdAt = Jiffy(message.createdAt.toLocal()); - final now = DateTime.now(); - final hourInfo = createdAt.format('h:mm a'); - - String dayInfo; - if (Jiffy(createdAt).isSame(now, Units.DAY)) { - dayInfo = 'TODAY'; - } else if (Jiffy(createdAt) - .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { - dayInfo = 'YESTERDAY'; - } else if (Jiffy(createdAt).isAfter( - now.subtract(Duration(days: 7)), - Units.DAY, - )) { - dayInfo = createdAt.format('EEEE').toUpperCase(); - } else if (Jiffy(createdAt).isAfter( - Jiffy(now).subtract(years: 1), - Units.DAY, - )) { - dayInfo = createdAt.format('dd/MM').toUpperCase(); - } else { - dayInfo = createdAt.format('dd/MM/yyyy').toUpperCase(); - } - + final theme = StreamChatTheme.of(context); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { @@ -56,59 +25,12 @@ class SystemMessage extends StatelessWidget { onMessageTap(message); } }, - child: Container( - width: double.infinity, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - divider, - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - message.text, - style: TextStyle( - fontSize: 10, - color: Theme.of(context) - .textTheme - .headline6 - .color - .withOpacity(.5), - fontWeight: FontWeight.bold, - ), - ), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: dayInfo, - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: ' AT'), - TextSpan(text: ' $hourInfo'), - ], - style: TextStyle( - fontWeight: FontWeight.normal, - ), - ), - style: TextStyle( - fontSize: 10, - color: Theme.of(context) - .textTheme - .headline6 - .color - .withOpacity(.5), - ), - ), - ], - ), - ), - divider, - ], + child: Text( + message.text, + textAlign: TextAlign.center, + softWrap: true, + style: theme.textTheme.captionBold.copyWith( + color: theme.colorTheme.grey, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index b33b8bc18..449d45842 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -63,15 +63,35 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// By default it calls [Navigator.pop] final VoidCallback onBackPressed; + /// Callback to call when the title is tapped. + final VoidCallback onTitleTap; + /// The message parent of this thread final Message parent; + /// Title widget + final Widget title; + + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + final Widget leading; + + /// AppBar actions + final List actions; + /// Instantiate a new ThreadHeader ThreadHeader({ Key key, @required this.parent, this.showBackButton = true, this.onBackPressed, + this.title, + this.subtitle, + this.leading, + this.actions, + this.onTitleTap, }) : preferredSize = Size.fromHeight(kToolbarHeight), super(key: key); @@ -81,51 +101,61 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { automaticallyImplyLeading: false, brightness: Theme.of(context).brightness, elevation: 1, - leading: showBackButton - ? StreamBackButton( - cid: StreamChannel.of(context).channel.cid, - onPressed: onBackPressed, - showUnreads: true, - ) - : SizedBox(), + leading: leading ?? + (showBackButton + ? StreamBackButton( + cid: StreamChannel.of(context).channel.cid, + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox()), backgroundColor: StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, centerTitle: true, - title: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Thread Reply', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), - SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, + actions: actions, + title: InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'with ', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ), - Flexible( - child: ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ), - ), + title ?? + Text( + 'Thread Reply', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), + SizedBox(height: 2), + subtitle ?? + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), + Flexible( + child: ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), + ), + ], + ), ], ), - ], + ), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index cf115f3b3..61a929870 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -14,41 +14,43 @@ class UnreadIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; - return StreamBuilder( - stream: cid != null - ? client.state.channels[cid].state.unreadCountStream - : client.state.totalUnreadCountStream, - initialData: cid != null - ? client.state.channels[cid].state.unreadCount - : client.state.totalUnreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return SizedBox(); - } - return Material( - borderRadius: BorderRadius.circular(8), - color: StreamChatTheme.of(context) - .channelPreviewTheme - .unreadCounterColor, - child: Padding( - padding: const EdgeInsets.only( - left: 5.0, - right: 5.0, - top: 2, - bottom: 1, - ), - child: Center( - child: Text( - '${snapshot.data}', - style: TextStyle( - fontSize: 11, - color: Colors.white, + return IgnorePointer( + child: StreamBuilder( + stream: cid != null + ? client.state.channels[cid].state.unreadCountStream + : client.state.totalUnreadCountStream, + initialData: cid != null + ? client.state.channels[cid].state.unreadCount + : client.state.totalUnreadCount, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == 0) { + return SizedBox(); + } + return Material( + borderRadius: BorderRadius.circular(8), + color: StreamChatTheme.of(context) + .channelPreviewTheme + .unreadCounterColor, + child: Padding( + padding: const EdgeInsets.only( + left: 5.0, + right: 5.0, + top: 2, + bottom: 1, + ), + child: Center( + child: Text( + '${snapshot.data}', + style: TextStyle( + fontSize: 11, + color: Colors.white, + ), ), ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/url_attachment.dart b/packages/stream_chat_flutter/lib/src/url_attachment.dart index b0584bce0..9dc5cdc8f 100644 --- a/packages/stream_chat_flutter/lib/src/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/url_attachment.dart @@ -28,6 +28,9 @@ class UrlAttachment extends StatelessWidget { Container( clipBehavior: Clip.antiAliasWithSaveLayer, margin: EdgeInsets.symmetric(horizontal: 8.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + ), child: Stack( children: [ CachedNetworkImage( @@ -39,6 +42,12 @@ class UrlAttachment extends StatelessWidget { left: 0.0, bottom: -1, child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + ), + color: StreamChatTheme.of(context).colorTheme.blueAlice, + ), child: Padding( padding: const EdgeInsets.only( top: 8.0, @@ -57,19 +66,10 @@ class UrlAttachment extends StatelessWidget { ), ), ), - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - ), - color: StreamChatTheme.of(context).colorTheme.blueAlice, - ), ), ), ], ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0), - ), ), Padding( padding: textPadding, diff --git a/packages/stream_chat_flutter/lib/src/user_avatar.dart b/packages/stream_chat_flutter/lib/src/user_avatar.dart index f38a9a68b..41860a7f1 100644 --- a/packages/stream_chat_flutter/lib/src/user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/user_avatar.dart @@ -39,26 +39,29 @@ class UserAvatar extends StatelessWidget { user.extraData['image'] != ''; final streamChatTheme = StreamChatTheme.of(context); - Widget avatar = ClipRRect( - clipBehavior: Clip.antiAlias, - borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, - child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, - decoration: BoxDecoration( - color: streamChatTheme.colorTheme.accentBlue, + Widget avatar = FittedBox( + fit: BoxFit.cover, + child: ClipRRect( + clipBehavior: Clip.antiAlias, + borderRadius: borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + decoration: BoxDecoration( + color: streamChatTheme.colorTheme.accentBlue, + ), + child: hasImage + ? CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: user.extraData['image'], + errorWidget: (_, __, ___) { + return streamChatTheme.defaultUserImage(context, user); + }, + fit: BoxFit.cover, + ) + : streamChatTheme.defaultUserImage(context, user), ), - child: hasImage - ? CachedNetworkImage( - filterQuality: FilterQuality.high, - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return streamChatTheme.defaultUserImage(context, user); - }, - fit: BoxFit.cover, - ) - : streamChatTheme.defaultUserImage(context, user), ), ); @@ -91,6 +94,7 @@ class UserAvatar extends StatelessWidget { alignment: onlineIndicatorAlignment, child: Material( type: MaterialType.circle, + color: streamChatTheme.colorTheme.white, child: Container( margin: const EdgeInsets.all(2.0), constraints: onlineIndicatorConstraints ?? @@ -103,7 +107,6 @@ class UserAvatar extends StatelessWidget { color: streamChatTheme.colorTheme.accentGreen, ), ), - color: streamChatTheme.colorTheme.white, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 7a88ee6cd..8d59757e0 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -36,63 +36,74 @@ Future showConfirmationDialog( )), builder: (context) { final effect = StreamChatTheme.of(context).colorTheme.borderTop; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 26.0), - if (icon != null) icon, - SizedBox(height: 26.0), - Text( - title, - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox(height: 7.0), - Text( - question, - textAlign: TextAlign.center, - ), - SizedBox(height: 36.0), - Container( - color: effect.color.withOpacity(effect.alpha ?? 1), - height: 1, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text( - cancelText, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5)), + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 26.0), + if (icon != null) icon, + SizedBox(height: 26.0), + Text( + title, + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox(height: 7.0), + Text( + question, + textAlign: TextAlign.center, + ), + SizedBox(height: 36.0), + Container( + color: effect.color.withOpacity(effect.alpha ?? 1), + height: 1, + ), + Row( + children: [ + Flexible( + child: Container( + alignment: Alignment.center, + child: TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + cancelText, + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5)), + ), + ), + ), ), - onPressed: () { - Navigator.of(context).pop(false); - }, - ), - TextButton( - child: Text( - okText, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed), + Flexible( + child: Container( + alignment: Alignment.center, + child: TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: Text( + okText, + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed), + ), + ), + ), ), - onPressed: () { - Navigator.pop(context, true); - }, - ), - ], - ), - ], + ], + ), + ], + ), ); }); } @@ -101,13 +112,13 @@ Future showInfoDialog( BuildContext context, { String title, Widget icon, - String question, + String details, String okText, StreamChatThemeData theme, }) { return showModalBottomSheet( - backgroundColor: - theme.colorTheme.white ?? StreamChatTheme.of(context).colorTheme.white, + backgroundColor: theme?.colorTheme?.white ?? + StreamChatTheme.of(context).colorTheme.white, context: context, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -115,54 +126,51 @@ Future showInfoDialog( topRight: Radius.circular(16.0), )), builder: (context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 26.0, - ), - if (icon != null) icon, - SizedBox( - height: 26.0, - ), - Text( - title, - style: theme.textTheme.headlineBold ?? - StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox( - height: 7.0, - ), - Text(question), - SizedBox( - height: 36.0, - ), - Container( - color: theme.colorTheme.black.withOpacity(.08) ?? - StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), - height: 1.0, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + if (icon != null) icon, + SizedBox( + height: 26.0, + ), + Text( + title, + style: theme?.textTheme?.headlineBold ?? + StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text(details), + SizedBox( + height: 36.0, + ), + Container( + color: theme?.colorTheme?.black?.withOpacity(.08) ?? + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Center( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, child: Text( okText, style: TextStyle( - color: theme.colorTheme.black.withOpacity(0.5) ?? - StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - fontWeight: FontWeight.w400), + color: theme?.colorTheme?.black?.withOpacity(0.5) ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + fontWeight: FontWeight.w400, + ), ), - onPressed: () { - Navigator.of(context).pop(); - }, ), - ], - ), - ], + ), + ], + ), ); }, ); diff --git a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart index 70a300054..80607b8e2 100644 --- a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; import 'stream_svg_icon.dart'; @@ -65,17 +67,27 @@ class _VideoThumbnailImageState extends State { builder: (_) { if (snapshot.hasError) { return widget.errorBuilder?.call(context, snapshot.error) ?? - Center(child: StreamSvgIcon.error()); + Center( + child: StreamSvgIcon.error(), + ); } if (!snapshot.hasData) { - return widget.placeholderBuilder?.call(context) ?? - Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - fit: widget.fit, - height: widget.height, - width: widget.width, - ); + return Container( + constraints: BoxConstraints.expand(), + child: widget.placeholderBuilder?.call(context) ?? + Shimmer.fromColors( + baseColor: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + highlightColor: + StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', + ), + ), + ); } return Image.memory( snapshot.data, diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 6fc905b5f..74c0c33e2 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -7,6 +7,7 @@ export 'src/channel_name.dart'; export 'src/channel_preview.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; +export 'src/message_action.dart'; export 'src/attachment/attachment.dart'; export 'src/full_screen_media.dart'; export 'src/image_header.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index a18801630..9cc6fe501 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.3.2-beta +version: 1.4.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -11,21 +11,20 @@ environment: dependencies: flutter: sdk: flutter - stream_chat_flutter_core: ^1.3.2-beta - flutter_app_badger: ^1.1.2 - photo_view: ^0.10.3 + stream_chat_flutter_core: ^1.4.0-beta + photo_view: ^0.11.0 rxdart: ^0.25.0 scrollable_positioned_list: ^0.1.8 jiffy: ^3.0.1 - flutter_svg: ^0.19.1 + flutter_svg: ^0.19.3 flutter_portal: ^0.3.0 cached_network_image: ^2.5.0 shimmer: ^1.1.2 flutter_markdown: ^0.5.2 url_launcher: ^5.7.10 emojis: ^0.9.3 - video_player: ^1.0.1 - chewie: ^0.12.1+1 + video_player: ^2.0.0 + chewie: ^1.0.0 file_picker: ^2.1.5 image_picker: ^0.6.7+17 flutter_keyboard_visibility: ^4.0.2 @@ -38,11 +37,11 @@ dependencies: flutter_slidable: ^0.5.7 clipboard: ^0.1.2+8 image_gallery_saver: ^1.6.7 - esys_flutter_share: ^1.0.2 - photo_manager: ^0.6.0 + share_plus: ^1.2.0 + photo_manager: ^1.0.0 transparent_image: ^1.0.0 ezanimation: ^0.4.1 - synchronized: ^2.2.0+2 + synchronized: ^2.1.0 characters: ^1.0.0 dio: ^3.0.10 path_provider: ^1.6.27 diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index ff7160acb..0bad0c4e2 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.4.0-beta + +* Added `MessageListCore.messageFilter` to filter messages locally +* Minor fixes and improvements + ## 1.3.2-beta * Update llc dependency 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 836e57c8f..1ab848780 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -107,7 +107,6 @@ class ChannelsBlocState extends State sort: sortOptions, options: options, paginationParams: paginationParams, - preferOffline: onlyOffline, )) { if (clear) { _channelsController.add(channels); @@ -173,11 +172,14 @@ class ChannelsBlocState extends State })); _subscriptions.add(client - .on(EventType.channelDeleted, EventType.notificationRemovedFromChannel) + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + ) .listen((e) { final channel = e.channel; - _channelsController - .add(List.from(channels..removeWhere((c) => c.cid == channel.cid))); + _channelsController.add(List.from( + (channels ?? [])..removeWhere((c) => c.cid == channel.cid))); })); } 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 64e8f2553..87f821c13 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 @@ -64,6 +64,7 @@ class MessageListCore extends StatefulWidget { this.showScrollToBottom = true, this.parentMessage, this.messageListController, + this.messageFilter, }) : assert(loadingBuilder != null), assert(emptyBuilder != null), assert(messageListBuilder != null), @@ -95,6 +96,9 @@ class MessageListCore extends StatefulWidget { /// first message or the parent of the conversation. final Message parentMessage; + /// Predicate used to filter messages + final bool Function(Message) messageFilter; + @override _MessageListCoreState createState() => _MessageListCoreState(); } @@ -106,6 +110,8 @@ class _MessageListCoreState extends State { bool get _isThreadConversation => widget.parentMessage != null; + OwnUser get _currentUser => streamChannel.channel.client.state.user; + int initialIndex; double initialAlignment; @@ -121,13 +127,16 @@ class _MessageListCoreState extends State { .map((threads) => threads[widget.parentMessage.id]) : streamChannel.channel.state?.messagesStream; + bool defaultFilter(Message m) { + final isMyMessage = m.user.id == _currentUser.id; + final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; + if (isDeletedOrShadowed && !isMyMessage) return false; + return true; + } + return StreamBuilder>( - stream: messagesStream?.map((messages) => messages - ?.where((e) => - (!e.isDeleted && e.shadowed != true) || - (e.isDeleted && - e.user.id == streamChannel.channel.client.state.user.id)) - ?.toList()), + stream: messagesStream?.map((messages) => + messages?.where(widget.messageFilter ?? defaultFilter)?.toList()), builder: (context, snapshot) { if (!snapshot.hasData) { return widget.loadingBuilder(context); @@ -135,7 +144,7 @@ class _MessageListCoreState extends State { return widget.errorWidgetBuilder(context, snapshot.error); } else { final messageList = snapshot.data?.reversed?.toList() ?? []; - if (messageList.isEmpty) { + if (messageList.isEmpty && !_isThreadConversation) { if (_upToDate) { return widget.emptyBuilder(context); } diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index b289193fb..82b02b72c 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.3.2-beta +version: 1.4.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -10,7 +10,7 @@ environment: flutter: ">=1.17.0" dependencies: - stream_chat: ^1.3.2-beta + stream_chat: ^1.4.0-beta flutter: sdk: flutter rxdart: ^0.25.0 diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index fab0e8741..161f2d596 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.4.0-beta + +* Update llc dependency + ## 1.3.0-beta * Update llc dependency diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index f70d9d93d..faa4fcd47 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.0 - stream_chat: ^1.3.0 + stream_chat: ^1.4.0 stream_chat_persistence: path: ../ diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 6923e9278..b82269ca9 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.3.0-beta +version: 1.4.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -15,7 +15,7 @@ dependencies: path: ^1.7.0 path_provider: ^1.6.27 sqlite3_flutter_libs: ^0.4.0+1 - stream_chat: ^1.3.0-beta + stream_chat: ^1.4.0-beta dev_dependencies: test: ^1.15.7