diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml index 2a53261b..02aeb03a 100644 --- a/.github/workflows/pr_title.yml +++ b/.github/workflows/pr_title.yml @@ -12,13 +12,15 @@ jobs: main: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v2.1.0 + - uses: amannn/action-semantic-pull-request@v3.4.0 with: scopes: | llc persistence core ui + doc + repo requireScope: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index f4a6a136..2b07ea78 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,6 +8,11 @@ - The `Message` class now has an `i18n` field for translations - The `User` class now has a `language` field for the user's language preference. +🐞 Fixed + +- [#563](https://github.com/GetStream/stream-chat-flutter/issues/563): `Channel.stopWatching()` not working +- [#575](https://github.com/GetStream/stream-chat-flutter/issues/575): Wrong `OwnUser.*` + ## 2.0.0 🛑️ Breaking Changes from `1.5.3` @@ -629,4 +634,4 @@ ## 0.0.2 -- first beta version \ No newline at end of file +- first beta version diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 514dd086..b681f17a 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -25,12 +25,14 @@ import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/own_user.dart'; import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/location.dart'; import 'package:stream_chat/src/ws/connection_status.dart'; import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:stream_chat/version.dart'; /// Handler function used for logging records. Function requires a single /// [LogRecord] as the only parameter. @@ -42,6 +44,10 @@ final _levelEmojiMapper = { Level.SEVERE: '🚨', }; +final _userAgent = 'stream-chat-dart-client-' + '${CurrentPlatform.name}-' + '${PACKAGE_VERSION.split('+')[0]}'; + /// 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 @@ -80,6 +86,7 @@ class StreamChatClient { location: location, connectTimeout: connectTimeout, receiveTimeout: receiveTimeout, + headers: {'X-Stream-Client': _userAgent}, ); _chatApi = chatApi ?? @@ -99,6 +106,7 @@ class StreamChatClient { tokenManager: _tokenManager, handler: handleEvent, logger: detachedLogger('🔌'), + queryParameters: {'X-Stream-Client': _userAgent}, ); _retryPolicy = retryPolicy ?? @@ -324,7 +332,7 @@ class StreamChatClient { } catch (e, stk) { if (e is StreamWebSocketError && e.isRetriable) { final event = await _chatPersistenceClient?.getConnectionInfo(); - if (event != null) return event.me?.merge(ownUser) ?? ownUser; + if (event != null) return ownUser.merge(event.me); } logger.severe('error connecting user : ${ownUser.id}', e, stk); rethrow; @@ -363,7 +371,7 @@ class StreamChatClient { try { final event = await _ws.connect(user); - return event.me?.merge(user) ?? user; + return user.merge(event.me); } catch (e, stk) { logger.severe('error connecting ws', e, stk); rethrow; diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 68f6c4f8..1ba7f333 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -289,6 +289,7 @@ class ChannelApi { ) async { final response = await _client.post( '${_getChannelUrl(channelId, channelType)}/stop-watching', + data: {}, ); return EmptyResponse.fromJson(response.data); } diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart index 406138c3..c19cdb8a 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -10,9 +10,7 @@ import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor. import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; import 'package:stream_chat/src/core/http/token_manager.dart'; -import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; import 'package:stream_chat/src/location.dart'; -import 'package:stream_chat/version.dart'; part 'stream_http_client_options.dart'; @@ -33,11 +31,14 @@ class StreamHttpClient { ..options.baseUrl = _options.baseUrl ..options.receiveTimeout = _options.receiveTimeout.inMilliseconds ..options.connectTimeout = _options.connectTimeout.inMilliseconds - ..options.queryParameters = {'api_key': apiKey} + ..options.queryParameters = { + 'api_key': apiKey, + ..._options.queryParameters, + } ..options.headers = { 'Content-Type': 'application/json', - 'X-Stream-Client': _options.userAgent, 'Content-Encoding': 'application/gzip', + ..._options.headers, } ..interceptors.addAll([ if (tokenManager != null) AuthInterceptor(this, tokenManager), diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart index faad46a1..01cdb12e 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart @@ -10,6 +10,8 @@ class StreamHttpClientOptions { this.location, this.connectTimeout = const Duration(seconds: 6), this.receiveTimeout = const Duration(seconds: 6), + this.queryParameters = const {}, + this.headers = const {}, }) : _baseUrl = baseUrl ?? _defaultBaseURL; final String _baseUrl; @@ -32,8 +34,20 @@ class StreamHttpClientOptions { /// received timeout, default to 6s final Duration receiveTimeout; - /// Get the current user agent - String get userAgent => 'stream-chat-dart-client-' - '${CurrentPlatform.name}-' - '${PACKAGE_VERSION.split('+')[0]}'; + /// Common query parameters. + /// + /// List values use the default [ListFormat.multiCompatible]. + /// + /// The value can be overridden per parameter by adding a [MultiParam] + /// object wrapping the actual List value and the desired format. + final Map queryParameters; + + /// Http request headers. + /// The keys of initial headers will be converted to lowercase, + /// for example 'Content-Type' will be converted to 'content-type'. + /// + /// The key of Header Map is case-insensitive + /// eg: content-type and Content-Type are + /// regard as the same key. + final Map headers; } diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart index f9d8a210..b26d811e 100644 --- a/packages/stream_chat/lib/src/ws/websocket.dart +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -41,11 +41,15 @@ class WebSocket with TimerHelper { this.reconnectionMonitorInterval = 10, this.healthCheckInterval = 20, this.reconnectionMonitorTimeout = 40, + this.queryParameters = const {}, }) : _logger = logger; /// final String apiKey; + /// Additional query parameters to be added to the websocket url + final Map queryParameters; + /// WS base url final String baseUrl; @@ -156,6 +160,7 @@ class WebSocket with TimerHelper { 'api_key': apiKey, 'authorization': token.rawValue, 'stream-auth-type': token.authType.raw, + ...queryParameters, }; final scheme = baseUrl.startsWith('https') ? 'wss' : 'ws'; final host = baseUrl.replaceAll(RegExp(r'(^\w+:|^)\/\/'), ''); diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index ab530ff1..c43f161f 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -595,14 +595,14 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/stop-watching'; - when(() => client.post(path)).thenAnswer( + when(() => client.post(path, data: {})).thenAnswer( (_) async => successResponse(path, data: {})); final res = await channelApi.stopWatching(channelId, channelType); expect(res, isNotNull); - verify(() => client.post(path)).called(1); + verify(() => client.post(path, data: {})).called(1); verifyNoMoreInteractions(client); }); } diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart index a03434fa..02cfc07a 100644 --- a/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart +++ b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart @@ -9,6 +9,8 @@ void main() { expect(options.baseUrl, 'https://chat-us-east-1.stream-io-api.com'); expect(options.connectTimeout, const Duration(seconds: 6)); expect(options.receiveTimeout, const Duration(seconds: 6)); + expect(options.queryParameters, const {}); + expect(options.headers, const {}); }); test('should override all the default set params', () { @@ -16,11 +18,15 @@ void main() { baseUrl: 'base-url', connectTimeout: Duration(seconds: 3), receiveTimeout: Duration(seconds: 3), + headers: {'test': 'test'}, + queryParameters: {'123': '123'}, ); expect(options.location, isNull); expect(options.baseUrl, 'base-url'); expect(options.connectTimeout, const Duration(seconds: 3)); expect(options.receiveTimeout, const Duration(seconds: 3)); + expect(options.headers, {'test': 'test'}); + expect(options.queryParameters, {'123': '123'}); }); group('should create baseUrl according to provided location', () { diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart index ab4c4984..7860a0d6 100644 --- a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -96,7 +96,7 @@ void main() { await client.get('path'); } catch (_) {} - verify(() => logger.info(any())).called(16); + verify(() => logger.info(any())).called(greaterThan(0)); }); test('loggingInterceptor should log error', () async { @@ -108,7 +108,7 @@ void main() { await client.get('path'); } catch (_) {} - verify(() => logger.severe(any())).called(8); + verify(() => logger.severe(any())).called(greaterThan(0)); }); test('`.lock` should lock the dio client', () async { diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 89e4ac44..16e65fa7 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,9 +1,14 @@ ## Upcoming -✅ Added +✅ Added +- Added `MessageListView.paginationLimit` - Allow the various ListView widgets to be themed via ThemeData classes +🐞 Fixed + +- Fix floating date divider not having a fixed size + ## 2.0.0 🛑️ Breaking Changes from `1.5.4` diff --git a/packages/stream_chat_flutter/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle index 9bb36eac..fbd6268e 100644 --- a/packages/stream_chat_flutter/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion 30 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -41,7 +41,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" minSdkVersion 21 - targetSdkVersion 29 + targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/packages/stream_chat_flutter/example/android/build.gradle b/packages/stream_chat_flutter/example/android/build.gradle index 9afae7f8..3e0873de 100644 --- a/packages/stream_chat_flutter/example/android/build.gradle +++ b/packages/stream_chat_flutter/example/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.5.20' repositories { google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.6.2' + classpath 'com.android.tools.build:gradle:4.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties index 493072b3..3df6b338 100644 --- a/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip 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 1cec9d5c..e3340ec7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -231,13 +231,21 @@ class _ChannelListViewState extends State { ); } - return ColoredBox( - color: ChannelListViewTheme.of(context).backgroundColor!, - child: LazyLoadScrollView( - onEndOfPage: () => _channelListController.paginateData!(), - child: child, - ), + child = LazyLoadScrollView( + onEndOfPage: () => _channelListController.paginateData!(), + child: child, ); + + final backgroundColor = ChannelListViewTheme.of(context).backgroundColor; + + if (backgroundColor != null) { + return ColoredBox( + color: backgroundColor, + child: child, + ); + } + + return child; } Widget _buildListView(BuildContext context, List channels) { diff --git a/packages/stream_chat_flutter/lib/src/gradient_avatar.dart b/packages/stream_chat_flutter/lib/src/gradient_avatar.dart new file mode 100644 index 00000000..3a6aba10 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/gradient_avatar.dart @@ -0,0 +1,258 @@ +import 'dart:math'; +import 'dart:ui'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// Fallback user avatar with a polygon gradient overlayed with text +class GradientAvatar extends StatefulWidget { + /// Constructor for [GradientAvatar] + const GradientAvatar({ + Key? key, + required this.name, + required this.userId, + }) : super(key: key); + + /// Name of user to shorten and display + final String name; + + /// ID of user to be used for key + final String userId; + + @override + _GradientAvatarState createState() => _GradientAvatarState(); +} + +class _GradientAvatarState extends State { + @override + Widget build(BuildContext context) => Center( + child: RepaintBoundary( + child: CustomPaint( + painter: DemoPainter( + widget.userId, + getShortenedName(widget.name), + DefaultTextStyle.of(context).style.fontFamily ?? 'Roboto', + ), + child: const SizedBox.expand(), + ), + ), + ); + + String getShortenedName(String name) { + var parts = name.split(' ')..removeWhere((e) => e == ''); + + if (parts.length > 2) { + parts = parts.take(2).toList(); + } + + var result = ''; + + for (var i = 0; i < parts.length; i++) { + result = result + parts[i][0].toUpperCase(); + } + + return result; + } +} + +/// Painter for bg polygon gradient +class DemoPainter extends CustomPainter { + /// Constructor for [DemoPainter] + DemoPainter( + this.userId, + this.username, + this.fontFamily, + ); + + /// Init grid row count + static const int rowCount = 5; + + /// Init grid column count + static const int columnCount = 5; + + /// User ID used for key + String userId; + + /// User name to display + String username; + + /// Font family to use + String fontFamily; + + @override + void paint(Canvas canvas, Size size) { + final rowUnit = size.width / columnCount; + final columnUnit = size.height / rowCount; + final rand = Random(userId.length); + + final squares = []; + final points = {}; + final gradient = colorGradients[rand.nextInt(colorGradients.length)]; + + for (var i = 0; i < rowCount; i++) { + for (var j = 0; j < columnCount; j++) { + final off1 = Offset(rowUnit * j, columnUnit * i); + final off2 = Offset(rowUnit * (j + 1), columnUnit * i); + final off3 = Offset(rowUnit * (j + 1), columnUnit * (i + 1)); + final off4 = Offset(rowUnit * j, columnUnit * (i + 1)); + + points.addAll([off1, off2, off3, off4]); + + final pointsList = points.toList(); + + final p1 = pointsList.indexOf(off1); + final p2 = pointsList.indexOf(off2); + final p3 = pointsList.indexOf(off3); + final p4 = pointsList.indexOf(off4); + + squares.add( + Offset4(p1, p2, p3, p4, i, j, rowCount, columnCount, gradient)); + } + } + + final list = transformPoints(points, size); + squares.forEach((e) => e.draw(canvas, list)); + + final smallerSide = size.width > size.height ? size.width : size.height; + + final textSize = smallerSide / 3; + + final dxShift = (username.length == 2 ? 1.45 : 0.9) * textSize / 2; + final dyShift = (username.length == 2 ? 1.0 : 1.65) * textSize / 2; + + final fontSize = username.length == 2 ? textSize : textSize * 1.5; + + TextPainter( + text: TextSpan( + text: username, + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: Colors.white.withOpacity(0.7), + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr) + ..layout(maxWidth: size.width) + ..paint( + canvas, + Offset( + (size.width / 2) - dxShift, + (size.height / 2) - dyShift, + ), + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + + /// Transforms initial grid into a polygon grid + List transformPoints(Set points, Size size) { + final transformedList = []; + final orgList = points.toList(); + final rand = Random(userId.length); + + for (var i = 0; i < points.length; i++) { + final orgDx = orgList[i].dx; + final orgDy = orgList[i].dy; + + if (orgDx == 0 || + orgDy == 0 || + orgDx == size.width || + orgDy == size.height) { + transformedList.add(Offset(orgDx, orgDy)); + continue; + } + + final sign1 = rand.nextInt(2) == 1 ? 1 : -1; + final sign2 = rand.nextInt(2) == 1 ? 1 : -1; + + final dx = 0.6 * sign1 * rand.nextInt(size.width ~/ columnCount); + final dy = 0.6 * sign2 * rand.nextInt(size.height ~/ rowCount); + + transformedList.add(Offset(orgDx + dx, orgDy + dy)); + } + + return transformedList; + } +} + +/// Class for storing and drawing four points of a polygon +class Offset4 { + /// Constructor for [Offset4] + Offset4( + this.p1, + this.p2, + this.p3, + this.p4, + this.row, + this.column, + this.rowSize, + this.colSize, + this.gradient, + ); + + /// Point 1 + int p1; + + /// Point 2 + int p2; + + /// Point 3 + int p3; + + /// Point 4 + int p4; + + /// Position of polygon on grid + int row; + + /// Position of polygon on grid + int column; + + /// Max row size + int rowSize; + + /// Max col size + int colSize; + + /// Gradient to be applied to polygon + List gradient; + + /// Draw the polygon on canvas + void draw(Canvas canvas, List points) { + final paint = Paint() + ..color = Color.fromARGB(255, Random().nextInt(255), + Random().nextInt(255), Random().nextInt(255)) + ..shader = ui.Gradient.linear( + points[p1], + points[p3], + gradient, + ); + + final backgroundPath = Path() + ..moveTo(points[p1].dx, points[p1].dy) + ..lineTo(points[p2].dx, points[p2].dy) + ..lineTo(points[p3].dx, points[p3].dy) + ..lineTo(points[p4].dx, points[p4].dy) + ..lineTo(points[p1].dx, points[p1].dy) + ..close(); + + canvas.drawPath(backgroundPath, paint); + } +} + +/// Gradient list for polygons +const colorGradients = [ + [Color(0xffffafbd), Color(0xffffc3a0)], + [Color(0xff2193b0), Color(0xff6dd5ed)], + [Color(0xffcc2b5e), Color(0xff753a88)], + [Color(0xffee9ca7), Color(0xffffdde1)], + [Color(0xff42275a), Color(0xff734b6d)], + [Color(0xffde6262), Color(0xffffb88c)], + [Color(0xff56ab2f), Color(0xffa8e063)], + [Color(0xff614385), Color(0xff516395)], + [Color(0xffeacda3), Color(0xffd6ae7b)], + [Color(0xff02aab0), Color(0xff00cdac)], +]; 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 b843f3b4..cdc088f0 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -166,11 +166,23 @@ class MessageListView extends StatefulWidget { this.showFloatingDateDivider = true, this.threadSeparatorBuilder, this.messageListController, + this.reverse = true, + this.paginationLimit = 20, }) : super(key: key); /// Function used to build a custom message widget final MessageBuilder? messageBuilder; + /// Whether the view scrolls in the reading direction. + /// + /// Defaults to true. + /// + /// See [ScrollView.reverse]. + final bool reverse; + + /// Limit used during pagination + final int paginationLimit; + /// Function used to build a custom system message widget final SystemMessageBuilder? systemMessageBuilder; @@ -328,6 +340,7 @@ class _MessageListViewState extends State { @override Widget build(BuildContext context) => MessageListCore( + paginationLimit: widget.paginationLimit, messageFilter: widget.messageFilter, loadingBuilder: widget.loadingBuilder ?? (context) => const Center( @@ -386,210 +399,217 @@ class _MessageListViewState extends State { 1 // parent message ; - return ColoredBox( - color: MessageListViewTheme.of(context).backgroundColor!, - child: Stack( - alignment: Alignment.center, - children: [ - ConnectionStatusBuilder( - statusBuilder: (context, status) { - var statusString = ''; - var showStatus = true; - switch (status) { - case ConnectionStatus.connected: - statusString = 'Connected'; - showStatus = false; - break; - case ConnectionStatus.connecting: - statusString = 'Reconnecting...'; - break; - case ConnectionStatus.disconnected: - statusString = 'Disconnected'; - break; - } + final child = Stack( + alignment: Alignment.center, + children: [ + ConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; + switch (status) { + case ConnectionStatus.connected: + statusString = 'Connected'; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = 'Reconnecting...'; + break; + case ConnectionStatus.disconnected: + statusString = 'Disconnected'; + break; + } - return InfoTile( - showMessage: widget.showConnectionStateTile && showStatus, - tileAnchor: Alignment.topCenter, - childAnchor: Alignment.topCenter, - message: statusString, - child: LazyLoadScrollView( - onPageScrollStart: () { - FocusScope.of(context).unfocus(); + return InfoTile( + showMessage: widget.showConnectionStateTile && showStatus, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: statusString, + child: LazyLoadScrollView( + onPageScrollStart: () { + FocusScope.of(context).unfocus(); + }, + onStartOfPage: () async { + _inBetweenList = false; + if (!_upToDate) { + _topPaginationActive = false; + _bottomPaginationActive = true; + return _paginateData( + streamChannel, + QueryDirection.bottom, + ); + } + }, + onEndOfPage: () async { + _inBetweenList = false; + _topPaginationActive = true; + _bottomPaginationActive = false; + return _paginateData( + streamChannel, + QueryDirection.top, + ); + }, + onInBetweenOfPage: () { + _inBetweenList = true; + }, + child: ScrollablePositionedList.separated( + key: ValueKey(initialIndex! + initialAlignment!), + itemPositionsListener: _itemPositionListener, + initialScrollIndex: initialIndex ?? 0, + initialAlignment: initialAlignment ?? 0, + physics: widget.scrollPhysics, + itemScrollController: _scrollController, + reverse: widget.reverse, + addAutomaticKeepAlives: false, + itemCount: itemCount, + + // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) + // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| + // ParentMessage -> 7 (count-1) + // Separator(ThreadSeparator) -> 6 (count-2) + // Header -> 6 (count-2) + // Separator(Header -> 8??T -> 0||52) -> 5 (count-3) + // TopLoader -> 5 (count-3) + // Separator(0) -> 4 (count-4) + // Message -> 4 (count-4) + // Separator(2||8) -> 3 (count-5) + // Message -> 3 (count-5) + // Separator(2||8) -> 2 (count-6) + // Message -> 2 (count-6) + // Separator(0) -> 1 (count-7) + // BottomLoader -> 1 (count-7) + // Separator(Footer -> 8??30) -> 0 (count-8) + // Footer -> 0 (count-8) + + separatorBuilder: (context, i) { + if (i == itemCount - 2) { + if (widget.parentMessage == null) { + return const Offstage(); + } + return _buildThreadSeparator(); + } + if (i == itemCount - 3) { + if (widget.headerBuilder == null) { + if (_isThreadConversation) return const Offstage(); + return const SizedBox(height: 52); + } + return const SizedBox(height: 8); + } + if (i == 0) { + if (widget.footerBuilder == null) { + return const SizedBox(height: 30); + } + return const SizedBox(height: 8); + } + + if (i == 1 || i == itemCount - 4) return const Offstage(); + + final message = messages[i - 1]; + final nextMessage = messages[i - 2]; + if (!Jiffy(message.createdAt.toLocal()).isSame( + nextMessage.createdAt.toLocal(), + Units.DAY, + )) { + final divider = widget.dateDividerBuilder != null + ? widget.dateDividerBuilder!( + nextMessage.createdAt.toLocal(), + ) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: divider, + ); + } + final timeDiff = + Jiffy(nextMessage.createdAt.toLocal()).diff( + message.createdAt.toLocal(), + Units.MINUTE, + ); + + final isNextUserSame = + message.user!.id == nextMessage.user?.id; + final isThread = message.replyCount! > 0; + final isDeleted = message.isDeleted; + if (timeDiff >= 1 || + !isNextUserSame || + isThread || + isDeleted) { + return const SizedBox(height: 8); + } + return const SizedBox(height: 2); }, - onStartOfPage: () async { - _inBetweenList = false; - if (!_upToDate) { - _topPaginationActive = false; - _bottomPaginationActive = true; - return _paginateData( - streamChannel, + itemBuilder: (context, i) { + if (i == itemCount - 1) { + if (widget.parentMessage == null) { + return const Offstage(); + } + return buildParentMessage(widget.parentMessage!); + } + + if (i == itemCount - 2) { + return widget.headerBuilder?.call(context) ?? + const Offstage(); + } + + if (i == itemCount - 3) { + return _buildLoadingIndicator( + streamChannel!, + QueryDirection.top, + ); + } + + if (i == 1) { + return _buildLoadingIndicator( + streamChannel!, QueryDirection.bottom, ); } - }, - onEndOfPage: () async { - _inBetweenList = false; - _topPaginationActive = true; - _bottomPaginationActive = false; - return _paginateData( - streamChannel, - QueryDirection.top, - ); - }, - onInBetweenOfPage: () { - _inBetweenList = true; - }, - child: ScrollablePositionedList.separated( - key: ValueKey(initialIndex! + initialAlignment!), - itemPositionsListener: _itemPositionListener, - initialScrollIndex: initialIndex ?? 0, - initialAlignment: initialAlignment ?? 0, - physics: widget.scrollPhysics, - itemScrollController: _scrollController, - reverse: true, - addAutomaticKeepAlives: false, - itemCount: itemCount, - // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) - // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| - // ParentMessage -> 7 (count-1) - // Separator(ThreadSeparator) -> 6 (count-2) - // Header -> 6 (count-2) - // Separator(Header -> 8??T -> 0||52) -> 5 (count-3) - // TopLoader -> 5 (count-3) - // Separator(0) -> 4 (count-4) - // Message -> 4 (count-4) - // Separator(2||8) -> 3 (count-5) - // Message -> 3 (count-5) - // Separator(2||8) -> 2 (count-6) - // Message -> 2 (count-6) - // Separator(0) -> 1 (count-7) - // BottomLoader -> 1 (count-7) - // Separator(Footer -> 8??30) -> 0 (count-8) - // Footer -> 0 (count-8) + if (i == 0) { + return widget.footerBuilder?.call(context) ?? + const Offstage(); + } - separatorBuilder: (context, i) { - if (i == itemCount - 2) { - if (widget.parentMessage == null) { - return const Offstage(); - } - return _buildThreadSeparator(); - } - if (i == itemCount - 3) { - if (widget.headerBuilder == null) { - if (_isThreadConversation) return const Offstage(); - return const SizedBox(height: 52); - } - return const SizedBox(height: 8); - } - if (i == 0) { - if (widget.footerBuilder == null) { - return const SizedBox(height: 30); - } - return const SizedBox(height: 8); - } + const bottomMessageIndex = 2; // 1 -> loader // 0 -> footer - if (i == 1 || i == itemCount - 4) return const Offstage(); + final message = messages[i - 2]; + Widget messageWidget; - final message = messages[i - 1]; - final nextMessage = messages[i - 2]; - if (!Jiffy(message.createdAt.toLocal()).isSame( - nextMessage.createdAt.toLocal(), - Units.DAY, - )) { - final divider = widget.dateDividerBuilder != null - ? widget.dateDividerBuilder!( - nextMessage.createdAt.toLocal(), - ) - : DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: divider, - ); - } - final timeDiff = - Jiffy(nextMessage.createdAt.toLocal()).diff( - message.createdAt.toLocal(), - Units.MINUTE, + if (i == bottomMessageIndex) { + messageWidget = _buildBottomMessage( + context, + message, + messages, + streamChannel!, + i - 2, ); - - final isNextUserSame = - message.user!.id == nextMessage.user?.id; - final isThread = message.replyCount! > 0; - final isDeleted = message.isDeleted; - if (timeDiff >= 1 || - !isNextUserSame || - isThread || - isDeleted) { - return const SizedBox(height: 8); - } - return const SizedBox(height: 2); - }, - itemBuilder: (context, i) { - if (i == itemCount - 1) { - if (widget.parentMessage == null) { - return const Offstage(); - } - return buildParentMessage(widget.parentMessage!); - } - - if (i == itemCount - 2) { - return widget.headerBuilder?.call(context) ?? - const Offstage(); - } - - if (i == itemCount - 3) { - return _buildLoadingIndicator( - streamChannel!, - QueryDirection.top, - ); - } - - if (i == 1) { - return _buildLoadingIndicator( - streamChannel!, - QueryDirection.bottom, - ); - } - - if (i == 0) { - return widget.footerBuilder?.call(context) ?? - const Offstage(); - } - - const bottomMessageIndex = - 2; // 1 -> loader // 0 -> footer - - final message = messages[i - 2]; - Widget messageWidget; - - if (i == bottomMessageIndex) { - messageWidget = _buildBottomMessage( - context, - message, - messages, - streamChannel!, - i - 2, - ); - } else { - messageWidget = buildMessage(message, messages, i - 2); - } - return messageWidget; - }, - ), + } else { + messageWidget = buildMessage(message, messages, i - 2); + } + return messageWidget; + }, ), - ); - }, - ), - if (widget.showScrollToBottom) _buildScrollToBottom(), - if (widget.showFloatingDateDivider) - _buildFloatingDateDivider(itemCount), - ], - ), + ), + ); + }, + ), + if (widget.showScrollToBottom) _buildScrollToBottom(), + if (widget.showFloatingDateDivider) + _buildFloatingDateDivider(itemCount), + ], ); + + final backgroundColor = MessageListViewTheme.of(context).backgroundColor; + + if (backgroundColor != null) { + return ColoredBox( + color: backgroundColor, + child: child, + ); + } + + return child; } Widget _buildThreadSeparator() { @@ -614,7 +634,10 @@ class _MessageListViewState extends State { } Positioned _buildFloatingDateDivider(int itemCount) => Positioned( - top: 20, + top: widget.reverse ? 20 : null, + bottom: widget.reverse ? null : 20, + left: 0, + right: 0, child: BetterStreamBuilder>( initialData: _itemPositionListener.itemPositions.value, stream: _itemPositionStream, @@ -646,7 +669,9 @@ class _MessageListViewState extends State { ); Future _paginateData( - StreamChannelState? channel, QueryDirection direction) => + StreamChannelState? channel, + QueryDirection direction, + ) => _messageListController.paginateData!(direction: direction); int? _getTopElementIndex(Iterable values) { @@ -706,9 +731,13 @@ class _MessageListViewState extends State { ); } }, - child: StreamSvgIcon.down( - color: _streamTheme.colorTheme.textHighEmphasis, - ), + child: widget.reverse + ? StreamSvgIcon.down( + color: _streamTheme.colorTheme.textHighEmphasis, + ) + : StreamSvgIcon.up( + color: _streamTheme.colorTheme.textHighEmphasis, + ), ), if (showUnreadCount) Positioned( 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 93488ba4..13f5ea55 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 @@ -144,61 +144,72 @@ class _MessageSearchListViewState extends State { widget.messageSearchListController ?? _defaultController; @override - Widget build(BuildContext context) => ColoredBox( - color: MessageSearchListViewTheme.of(context).backgroundColor!, - child: MessageSearchListCore( - filters: widget.filters, - sortOptions: widget.sortOptions, - messageQuery: widget.messageQuery, - paginationParams: widget.paginationParams, - messageFilters: widget.messageFilters, - messageSearchListController: _messageSearchListController, - emptyBuilder: widget.emptyBuilder ?? - (context) => LayoutBuilder( - builder: (context, viewportConstraints) => - SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: const Center( - child: Text('There are no messages currently'), - ), - ), + Widget build(BuildContext context) { + final messageSearchListCore = MessageSearchListCore( + filters: widget.filters, + sortOptions: widget.sortOptions, + messageQuery: widget.messageQuery, + paginationParams: widget.paginationParams, + messageFilters: widget.messageFilters, + messageSearchListController: _messageSearchListController, + emptyBuilder: widget.emptyBuilder ?? + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: const Center( + child: Text('There are no messages currently'), ), ), - errorBuilder: widget.errorBuilder ?? - (BuildContext context, dynamic error) { - if (error is Error) { - print(error.stackTrace); - } - return InfoTile( - showMessage: widget.showErrorTile, - tileAnchor: Alignment.topCenter, - childAnchor: Alignment.topCenter, - message: 'An error occurred.', - child: Container(), - ); - }, - loadingBuilder: widget.loadingBuilder ?? - (context) => LayoutBuilder( - builder: (context, viewportConstraints) => - SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: const Center( - child: CircularProgressIndicator(), - ), - ), + ), + ), + errorBuilder: widget.errorBuilder ?? + (BuildContext context, dynamic error) { + if (error is Error) { + print(error.stackTrace); + } + return InfoTile( + showMessage: widget.showErrorTile, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: 'An error occurred.', + child: Container(), + ); + }, + loadingBuilder: widget.loadingBuilder ?? + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: const Center( + child: CircularProgressIndicator(), ), ), - childBuilder: widget.childBuilder ?? _buildListView, - ), + ), + ), + childBuilder: widget.childBuilder ?? _buildListView, + ); + + final backgroundColor = + MessageSearchListViewTheme.of(context).backgroundColor; + + if (backgroundColor != null) { + return ColoredBox( + color: backgroundColor, + child: messageSearchListCore, ); + } + + return messageSearchListCore; + } Widget _separatorBuilder(BuildContext context, int index) => Container( height: 1, 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 94ba50e4..9d2df266 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -1,12 +1,11 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/channel_header.dart'; import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/gradient_avatar.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/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -310,10 +309,9 @@ class StreamChatThemeData { colorTheme: colorTheme, primaryIconTheme: iconTheme, defaultUserImage: (context, user) => Center( - child: CachedNetworkImage( - filterQuality: FilterQuality.high, - imageUrl: getRandomPicUrl(user), - fit: BoxFit.cover, + child: GradientAvatar( + name: user.name, + userId: user.id, ), ), channelPreviewTheme: channelPreviewTheme, 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 fb5017e1..73c33131 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -37,6 +37,18 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); + /// [StreamSvgIcon] type + factory StreamSvgIcon.up({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_up.svg', + color: color, + width: size, + height: size, + ); + /// [StreamSvgIcon] type factory StreamSvgIcon.attach({ double? size, diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index ad5abb24..39d4453d 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -160,38 +160,48 @@ class _UserListViewState extends State @override Widget build(BuildContext context) { - final child = ColoredBox( - color: UserListViewTheme.of(context).backgroundColor!, - child: UserListCore( - errorBuilder: widget.errorBuilder ?? - (BuildContext context, Object err) => _buildError(err), - emptyBuilder: widget.emptyBuilder ?? (context) => _buildEmpty(), - loadingBuilder: widget.loadingBuilder ?? - (context) => LayoutBuilder( - builder: (context, viewportConstraints) => - SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: const Center( - child: CircularProgressIndicator(), - ), + final userListCore = UserListCore( + errorBuilder: widget.errorBuilder ?? + (BuildContext context, Object err) => _buildError(err), + emptyBuilder: widget.emptyBuilder ?? (context) => _buildEmpty(), + loadingBuilder: widget.loadingBuilder ?? + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: const Center( + child: CircularProgressIndicator(), ), ), ), - listBuilder: - widget.listBuilder ?? (context, list) => _buildListView(list), - pagination: widget.pagination, - sort: widget.sort, - filter: widget.filter, - presence: widget.presence, - groupAlphabetically: widget.groupAlphabetically, - userListController: _userListController, - ), + ), + listBuilder: + widget.listBuilder ?? (context, list) => _buildListView(list), + pagination: widget.pagination, + sort: widget.sort, + filter: widget.filter, + presence: widget.presence, + groupAlphabetically: widget.groupAlphabetically, + userListController: _userListController, ); + final backgroundColor = UserListViewTheme.of(context).backgroundColor; + + Widget child; + + if (backgroundColor != null) { + child = ColoredBox( + color: backgroundColor, + child: userListCore, + ); + } else { + child = userListCore; + } + if (!widget.pullToRefresh) { return child; } else { diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 39aabb85..12049fc6 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -14,6 +14,7 @@ export 'src/deleted_message.dart'; export 'src/full_screen_media.dart'; export 'src/gallery_footer.dart'; export 'src/gallery_header.dart'; +export 'src/gradient_avatar.dart'; export 'src/info_tile.dart'; export 'src/mention_tile.dart'; export 'src/message_action.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_up.svg b/packages/stream_chat_flutter/lib/svgs/Icon_up.svg new file mode 100644 index 00000000..60e6888f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 590ed055..cb46c470 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -30,8 +30,8 @@ dependencies: lottie: ^1.0.1 meta: ^1.3.0 path_provider: ^2.0.1 - photo_manager: ^1.1.6 - photo_view: ^0.11.1 + photo_manager: ^1.2.6+1 + photo_view: ^0.12.0 rxdart: ^0.27.0 scrollable_positioned_list: ^0.2.0-nullsafety.0 share_plus: ^2.0.3 diff --git a/packages/stream_chat_flutter/test/flutter_test_config.dart b/packages/stream_chat_flutter/test/flutter_test_config.dart index c09db700..bfda23d0 100644 --- a/packages/stream_chat_flutter/test/flutter_test_config.dart +++ b/packages/stream_chat_flutter/test/flutter_test_config.dart @@ -1,9 +1,35 @@ import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; Future testExecutable(FutureOr Function() testMain) async { await loadAppFonts(); + goldenFileComparator = + CustomGoldenFileComparator(Uri.parse('test/src/goldens')); return testMain(); } + +class CustomGoldenFileComparator extends LocalFileComparator { + CustomGoldenFileComparator(Uri testFile) : super(testFile); + + @override + Future compare(Uint8List imageBytes, Uri golden) async { + final result = await GoldenFileComparator.compareLists( + imageBytes, + await getGoldenBytes(golden), + ); + + if (!result.passed && result.diffPercent > 0.05) { + final error = await generateFailureOutput(result, golden, basedir); + throw FlutterError(error); + } + return true; + } + + @override + Future update(Uri golden, Uint8List imageBytes) => + super.update(golden, imageBytes); +} diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png new file mode 100644 index 00000000..bf15f6af Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png new file mode 100644 index 00000000..bd466f79 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png new file mode 100644 index 00000000..d7d671c4 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png new file mode 100644 index 00000000..5381c0f0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_text.png b/packages/stream_chat_flutter/test/src/goldens/message_text.png index ecd93721..dda158b2 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_text.png and b/packages/stream_chat_flutter/test/src/goldens/message_text.png differ diff --git a/packages/stream_chat_flutter/test/src/gradient_avatar_test.dart b/packages/stream_chat_flutter/test/src/gradient_avatar_test.dart new file mode 100644 index 00000000..f8decfef --- /dev/null +++ b/packages/stream_chat_flutter/test/src/gradient_avatar_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/gradient_avatar.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: GradientAvatar(name: 'demo user', userId: 'demo123'), + ), + ), + ), + ), + ), + ); + + expect(find.byType(GradientAvatar), findsOneWidget); + }, + ); + + testGoldens( + 'golden test for the name "demo user"', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: GradientAvatar(name: 'demo user', userId: 'demo123'), + ), + ), + ), + ), + ); + + await screenMatchesGolden(tester, 'gradient_avatar_0'); + }, + ); + + testGoldens( + 'golden test for the name "demo"', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: GradientAvatar(name: 'demo', userId: 'demo1'), + ), + ), + ), + ), + ); + + await screenMatchesGolden(tester, 'gradient_avatar_1'); + }, + ); + + testGoldens( + 'control special character test', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: GradientAvatar( + name: 'd123@/d de:\$as', + userId: 'demo123', + ), + ), + ), + ), + ), + ); + + await screenMatchesGolden(tester, 'gradient_avatar_2'); + }, + ); + + testGoldens( + 'control special character test 2', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: GradientAvatar(name: '123@/d \$as', userId: 'demo123'), + ), + ), + ), + ), + ); + + await screenMatchesGolden(tester, 'gradient_avatar_3'); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_text_test.dart b/packages/stream_chat_flutter/test/src/message_text_test.dart index f64e2bf1..765f7f2c 100644 --- a/packages/stream_chat_flutter/test/src/message_text_test.dart +++ b/packages/stream_chat_flutter/test/src/message_text_test.dart @@ -80,7 +80,7 @@ void main() { }); const messageText = ''' - a message. +a message. with multiple lines and a list: - a. okasd diff --git a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart index 1e3c9f5e..acf485c0 100644 --- a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart +++ b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart @@ -6,32 +6,8 @@ import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; -import 'simple_frame.dart'; void main() { - testGoldens( - 'it should show no reactions', - (WidgetTester tester) async { - await tester.pumpWidgetBuilder( - SimpleFrame( - child: StreamChatTheme( - data: StreamChatThemeData(), - child: const SizedBox( - child: ReactionBubble( - reactions: [], - borderColor: Colors.black, - backgroundColor: Colors.white, - maskColor: Colors.white, - ), - ), - ), - ), - surfaceSize: const Size(100, 100), - ); - await screenMatchesGolden(tester, 'reaction_bubble_0'); - }, - ); - testGoldens( 'it should show a like - light theme', (WidgetTester tester) async { diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 2f7abfe2..a823a0db 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,8 @@ +## Upcoming + +✅ Added +- Added `MessageListCore.paginationLimit` + ## 2.0.0 🛑️ Breaking Changes from `1.5.3` 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 dc90cc91..4acf0ba7 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 @@ -71,6 +71,7 @@ class MessageListCore extends StatefulWidget { this.parentMessage, this.messageListController, this.messageFilter, + this.paginationLimit = 20, }) : super(key: key); /// A [MessageListController] allows pagination. @@ -86,6 +87,9 @@ class MessageListCore extends StatefulWidget { /// Function used to build an empty widget final WidgetBuilder emptyBuilder; + /// Limit used to paginate messages + final int paginationLimit; + /// Callback triggered when an error occurs while performing the given /// request. /// @@ -163,13 +167,20 @@ class MessageListCoreState extends State { /// Fetches more messages with updated pagination and updates the widget. /// /// Optionally pass the fetch direction, defaults to [QueryDirection.top] + /// Optionally pass a limit, defaults to 20 Future paginateData({ QueryDirection direction = QueryDirection.top, }) { if (!_isThreadConversation) { - return _streamChannel!.queryMessages(direction: direction); + return _streamChannel!.queryMessages( + direction: direction, + limit: widget.paginationLimit, + ); } else { - return _streamChannel!.getReplies(widget.parentMessage!.id); + return _streamChannel!.getReplies( + widget.parentMessage!.id, + limit: widget.paginationLimit, + ); } } @@ -179,7 +190,10 @@ class MessageListCoreState extends State { if (newStreamChannel != _streamChannel) { if (_streamChannel == null /*only first time*/ && _isThreadConversation) { - newStreamChannel.getReplies(widget.parentMessage!.id); + newStreamChannel.getReplies( + widget.parentMessage!.id, + limit: widget.paginationLimit, + ); } _streamChannel = newStreamChannel; } @@ -197,7 +211,10 @@ class MessageListCoreState extends State { if (widget.parentMessage?.id != widget.parentMessage?.id) { if (_isThreadConversation) { - _streamChannel!.getReplies(widget.parentMessage!.id); + _streamChannel!.getReplies( + widget.parentMessage!.id, + limit: widget.paginationLimit, + ); } } } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 1dcf9c3c..fb7b79f2 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -147,9 +147,18 @@ class StreamChannelState extends State { } /// Calls [channel.query] updating [queryMessage] stream - Future queryMessages({QueryDirection? direction = QueryDirection.top}) { - if (direction == QueryDirection.top) return _queryTopMessages(); - return _queryBottomMessages(); + Future queryMessages({ + QueryDirection? direction = QueryDirection.top, + int limit = 20, + }) { + if (direction == QueryDirection.top) { + return _queryTopMessages( + limit: limit, + ); + } + return _queryBottomMessages( + limit: limit, + ); } /// Calls [channel.getReplies] updating [queryMessage] stream diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index b038411b..e61d0abb 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -151,7 +151,9 @@ void main() { (tester) async { const messageListCoreKey = Key('messageListCore'); final controller = MessageListController(); + const paginationLimit = 10; final messageListCore = MessageListCore( + paginationLimit: paginationLimit, key: messageListCoreKey, messageListBuilder: (_, __) => const Offstage(), loadingBuilder: (BuildContext context) => const Offstage(), @@ -165,10 +167,6 @@ void main() { final mockChannel = MockChannel(); when(() => mockChannel.state.isUpToDate).thenReturn(true); - // when(() => mockChannel.query( - // messagesPagination: any(named: 'messagesPagination'), - // preferOffline: any(named: 'preferOffline'), - // )).thenAnswer((_) => mockChannel.state); final messages = _generateMessages(); when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.messagesStream) @@ -191,7 +189,10 @@ void main() { await coreState.paginateData(); verify(() => mockChannel.query( - messagesPagination: any(named: 'messagesPagination'), + messagesPagination: any( + named: 'messagesPagination', + that: wrapMatcher((it) => it.limit == paginationLimit), + ), preferOffline: any(named: 'preferOffline'), )).called(1); }, diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index 62a3df15..81e629b6 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/dao/channel_query_dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; @@ -84,9 +82,9 @@ void main() { cid: cids[index], createdBy: users[index], config: ChannelConfig(), - extraData: {'test_custom_field': math.Random().nextInt(100)}, + extraData: {'test_custom_field': 3 + index}, createdAt: now, - memberCount: math.Random().nextInt(100), + memberCount: 3 + index, lastMessageAt: now.add(Duration(hours: index)), ), ).reversed.toList(growable: false); @@ -99,6 +97,8 @@ void main() { } group('getChannels', () { + tearDown(() async => database.flush()); + final filter = Filter.in_('members', const ['testUserId']); test('should return empty list of channels', () async {