diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 5d76e97938..6cf9cadbca 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,8 @@ 🐞 Fixed - Fixed `.replaceMentions` not escaping special characters in the username. +- Fixed `GradientAvatars` for users with same-length IDs would have identical + colors. [[#2369]](https://github.com/GetStream/stream-chat-flutter/issues/2369) ## 9.16.0 diff --git a/packages/stream_chat_flutter/lib/src/avatars/gradient_avatar.dart b/packages/stream_chat_flutter/lib/src/avatars/gradient_avatar.dart index 93483619fb..b8ce9740e3 100644 --- a/packages/stream_chat_flutter/lib/src/avatars/gradient_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/avatars/gradient_avatar.dart @@ -1,264 +1,258 @@ import 'dart:math'; import 'dart:ui' as ui; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; - -/// {@template streamGradientAvatar} -/// Fallback user avatar with a polygon gradient overlaid with text -/// {@endtemplate} -class StreamGradientAvatar extends StatefulWidget { - /// {@macro streamGradientAvatar} +import 'package:stream_chat_flutter/src/utils/utils.dart'; + +/// Fallback avatar with a polygon gradient background and initials overlay. +/// +/// Creates a deterministic gradient avatar based on the user's [userId] that +/// displays user initials over a colorful jittered polygon background. Each +/// user gets a consistent gradient color and jitter pattern based on their ID. +/// +/// The avatar uses a jittered polygon grid to create visual variety while +/// maintaining deterministic appearance for the same user across sessions. +/// +/// Example usage: +/// ```dart +/// StreamGradientAvatar( +/// name: 'John Doe', +/// userId: 'user-123', +/// jitterIntensity: 0.6, +/// ) +/// ``` +class StreamGradientAvatar extends StatelessWidget { + /// Creates a gradient avatar with the specified [name] and [userId]. + /// + /// The [jitterIntensity] controls how much randomness is applied to the + /// polygon grid, where 0.0 creates a perfectly regular grid and 1.0 creates + /// maximum randomness. const StreamGradientAvatar({ super.key, required this.name, required this.userId, + this.jitterIntensity = 0.4, }); - /// Name of user to shorten and display + /// The display name used to generate initials for the avatar. final String name; - /// ID of user to be used for key + /// The unique identifier used to generate consistent colors and jitter. final String userId; - @override - _StreamGradientAvatarState createState() => _StreamGradientAvatarState(); -} + /// The intensity of polygon jitter applied to the background. + /// + /// Must be between 0.0 (no jitter) and 1.0 (maximum jitter). + /// Defaults to 0.4 for moderate visual variety. + final double jitterIntensity; -class _StreamGradientAvatarState extends State { @override Widget build(BuildContext context) { - return Center( - child: RepaintBoundary( - child: CustomPaint( - painter: PolygonGradientPainter( - widget.userId, - getShortenedName(widget.name), - DefaultTextStyle.of(context).style.fontFamily ?? 'Roboto', - ), - child: const SizedBox.expand(), + final jitterSeed = userId.hashCode; + final gradient = _palettes[jitterSeed.abs() % _palettes.length]; + + return RepaintBoundary( + child: CustomPaint( + painter: PolygonGradientPainter( + gradient: gradient, + jitterSeed: jitterSeed, + jitterIntensity: jitterIntensity, ), + child: Center(child: _Initials(username: name)), ), ); } +} - String getShortenedName(String name) { - var parts = name.split(' ')..removeWhere((e) => e == ''); - - if (parts.length > 2) { - parts = parts.take(2).toList(); - } - - var result = ''; +/// Displays user initials with responsive sizing over gradient backgrounds. +/// +/// Extracts and displays up to two initials from the username with automatic +/// font sizing based on the available space and number of characters. +class _Initials extends StatelessWidget { + /// Creates an initials widget for the given [username]. + const _Initials({ + required this.username, + }); - for (var i = 0; i < parts.length; i++) { - result = result + parts[i][0].toUpperCase(); - } + /// The username from which to extract and display initials. + final String username; - return result; + @override + Widget build(BuildContext context) { + final initials = username.initials ?? '?'; + + return LayoutBuilder( + builder: (context, constraints) { + final side = min(constraints.maxWidth, constraints.maxHeight); + final fontSize = initials.length == 2 ? side / 3 : side / 2; + + return Text( + initials, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.7), + ), + ); + }, + ); } } -/// {@template polygonGradientPainter} -/// Painter for bg polygon gradient -/// {@endtemplate} +/// Custom painter that draws a jittered polygon grid with gradient fills. +/// +/// Creates a grid of polygon cells with optional jitter displacement and fills +/// each cell with a linear gradient. The grid layout is customizable through +/// [rows] and [columns], while visual variety is controlled by the [jitter] +/// configuration. +/// +/// Each polygon cell is filled with a linear gradient that creates smooth +/// color transitions across the entire painted area. class PolygonGradientPainter extends CustomPainter { - /// {@macro polygonGradientPainter} - PolygonGradientPainter( - this.userId, - this.username, - this.fontFamily, - ); + /// Creates a polygon gradient painter with the specified configuration. + /// + /// The [jitter] controls the random displacement applied to interior grid + /// points, while [gradient] defines the colors used for filling the polygon + /// cells. + PolygonGradientPainter({ + this.rows = 5, + this.columns = 5, + this.jitterSeed, + this.jitterIntensity = 0.4, + required this.gradient, + }); - /// Initial grid row count - static const int rowCount = 5; + /// The number of rows in the polygon grid. + final int rows; - /// Initial grid column count - static const int columnCount = 5; + /// The number of columns in the polygon grid. + final int columns; - /// User ID used for key - String userId; + /// The seed for the jitter configuration to ensure consistent randomness. + final int? jitterSeed; - /// User name to display - String username; + /// The intensity of jitter applied to the grid points. + final double jitterIntensity; - /// Font family to use - String fontFamily; + /// The gradient colors used to fill each polygon cell. + final List gradient; @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]); + if (size.isEmpty) return; - final pointsList = points.toList(); - - final p1 = pointsList.indexOf(off1); - final p2 = pointsList.indexOf(off2); - final p3 = pointsList.indexOf(off3); - final p4 = pointsList.indexOf(off4); + final jitter = Jitter( + seed: jitterSeed, + intensity: jitterIntensity, + ); - squares.add( - Offset4(p1, p2, p3, p4, i, j, rowCount, columnCount, gradient), - ); + final cols1 = columns + 1; + final rows1 = rows + 1; + final cellW = size.width / columns; + final cellH = size.height / rows; + + final maxDx = cellW; + final maxDy = cellH; + + // Build jittered grid points + final points = List.filled(cols1 * rows1, Offset.zero); + for (var r = 0; r < rows1; r++) { + final y = r * cellH; + final rowBase = r * cols1; + for (var c = 0; c < cols1; c++) { + final x = c * cellW; + final isBorder = r == 0 || c == 0 || r == rows1 - 1 || c == cols1 - 1; + + if (isBorder) { + points[rowBase + c] = Offset(x, y); + } else { + points[rowBase + c] = jitter.applyTo(Offset(x, y), maxDx, maxDy); + } } } - 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, - // ignore: deprecated_member_use - 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; + // Build cells from jittered points and draw them + for (var r = 0; r < rows; r++) { + final base = r * cols1; + final next = (r + 1) * cols1; + for (var c = 0; c < columns; c++) { + final a = points[base + c]; + final b = points[base + c + 1]; + final d = points[next + c]; + final e = points[next + c + 1]; + + PolygonCell(a, b, e, d).paint(canvas, gradient); } - - final sign1 = rand.nextInt(2) == 1 ? 1 : -1; - final sign2 = rand.nextInt(2) == 1 ? 1 : -1; - - final dx = sign1 * 0.6 * rand.nextInt(size.width ~/ columnCount); - final dy = sign2 * 0.6 * rand.nextInt(size.height ~/ rowCount); - - transformedList.add(Offset(orgDx + dx, orgDy + dy)); } - - return transformedList; } + + @override + bool shouldRepaint(covariant PolygonGradientPainter old) => + old.rows != rows || + old.columns != columns || + old.jitterSeed != jitterSeed || + old.jitterIntensity != jitterIntensity || + !const ListEquality().equals(old.gradient, gradient); } -/// {@template offset4} -/// Class for storing and drawing four points of a polygon. -/// {@endtemplate} -class Offset4 { - /// {@macro offset4} - Offset4( - this.p1, - this.p2, - this.p3, - this.p4, - this.row, - this.column, - this.rowSize, - this.colSize, - this.gradient, +/// A quadrilateral polygon cell defined by four corner points. +/// +/// Represents a single cell in the jittered polygon grid that can paint itself +/// with a linear gradient fill. The cell is defined by four corner points that +/// form a quadrilateral shape. +class PolygonCell { + /// Creates a polygon cell with the specified corner points. + /// + /// Points should be ordered to form a proper quadrilateral shape. + const PolygonCell( + this.pointA, + this.pointB, + this.pointC, + this.pointD, ); - /// 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) + /// The first corner point of the polygon cell. + final Offset pointA; + + /// The second corner point of the polygon cell. + final Offset pointB; + + /// The third corner point of the polygon cell. + final Offset pointC; + + /// The fourth corner point of the polygon cell. + final Offset pointD; + + /// Paints this polygon cell on the [canvas] with the specified [gradient]. + /// + /// Creates a linear gradient shader from [pointA] to [pointC] and fills + /// the quadrilateral path formed by all four corner points. + void paint( + Canvas canvas, + List gradient, + ) { + final shader = ui.Gradient.linear(pointA, pointC, gradient); + final paint = Paint()..shader = shader; + + final path = Path() + ..moveTo(pointA.dx, pointA.dy) + ..lineTo(pointB.dx, pointB.dy) + ..lineTo(pointC.dx, pointC.dy) + ..lineTo(pointD.dx, pointD.dy) ..close(); - canvas.drawPath(backgroundPath, paint); + canvas.drawPath(path, paint); } } -/// Gradient list for polygons -const colorGradients = [ +/// Predefined gradient color palettes for avatar backgrounds. +/// +/// Contains a curated collection of two-color gradients that provide visually +/// appealing and accessible color combinations for user avatars. Each palette +/// consists of two colors that create smooth linear gradients. +const _palettes = >[ [Color(0xffffafbd), Color(0xffffc3a0)], [Color(0xff2193b0), Color(0xff6dd5ed)], [Color(0xffcc2b5e), Color(0xff753a88)], diff --git a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart index 9d997c58d5..974a1c2fc6 100644 --- a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart @@ -55,9 +55,9 @@ class ChannelInfoDialog extends StatelessWidget { (e) => e.user?.id != userAsMember.user?.id, ) .user!, - constraints: const BoxConstraints( - maxHeight: 64, - maxWidth: 64, + constraints: const BoxConstraints.tightFor( + height: 64, + width: 64, ), borderRadius: BorderRadius.circular(32), onlineIndicatorConstraints: diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index c75c04a7af..164a0ec053 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -57,6 +57,18 @@ extension StringExtension on String { } } + /// Returns the initials of a string. + String? get initials { + final parts = split(RegExp(r'\s+')).where((e) => e.isNotEmpty).toList(); + + if (parts.isEmpty) return null; + + var initials = parts[0].characters.first.toUpperCase(); + if (parts.length > 1) initials += parts[1].characters.first.toUpperCase(); + + return initials; + } + /// Returns whether the string contains only emoji's or not. /// /// Emojis guidelines diff --git a/packages/stream_chat_flutter/lib/src/utils/jitter.dart b/packages/stream_chat_flutter/lib/src/utils/jitter.dart new file mode 100644 index 0000000000..cf121f0c9b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils/jitter.dart @@ -0,0 +1,86 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:math'; +import 'dart:ui'; + +/// Configuration for applying random displacement to polygon grid points. +/// +/// Manages random displacement generation with a specified intensity level +/// using a seeded [Random] instance for deterministic jitter patterns. The +/// jitter creates visual variety in polygon backgrounds while maintaining +/// consistency for the same seed. +/// +/// Example usage: +/// ```dart +/// final jitter = Jitter.medium(seed: userId.hashCode); +/// final jitteredPoint = jitter.applyTo(point, maxDx, maxDy); +/// ``` +class Jitter { + /// Creates a jitter configuration with the specified [intensity] and + /// optional [seed]. + /// + /// The [intensity] is automatically clamped to the range 0.0 to 1.0. When + /// [seed] is null, uses a random seed for non-deterministic behavior. + factory Jitter({int? seed, double intensity = 0.4}) { + return Jitter.custom( + random: Random(seed), + intensity: intensity, + ); + } + + /// Creates a jitter configuration with a custom [random] instance and + /// [intensity]. + /// + /// The [intensity] is automatically clamped to the range 0.0 to 1.0. This + /// factory allows reusing an existing [Random] instance for performance. + factory Jitter.custom({ + required Random random, + double intensity = 0.4, + }) { + final clampedIntensity = intensity.clamp(0.0, 1.0); + return Jitter._(random, clampedIntensity); + } + + /// Creates a jitter configuration with no displacement (regular grid). + factory Jitter.none({int? seed}) => Jitter(intensity: 0, seed: seed); + + /// Creates a jitter configuration with light displacement. + factory Jitter.light({int? seed}) => Jitter(intensity: 0.2, seed: seed); + + /// Creates a jitter configuration with medium displacement. + factory Jitter.medium({int? seed}) => Jitter(intensity: 0.4, seed: seed); + + /// Creates a jitter configuration with heavy displacement. + factory Jitter.heavy({int? seed}) => Jitter(intensity: 0.6, seed: seed); + + /// Creates a jitter configuration with maximum displacement. + factory Jitter.max({int? seed}) => Jitter(intensity: 1, seed: seed); + + // Creates a jitter configuration with the specified [_random] and [intensity] + Jitter._(this._random, this.intensity); + + // The random number generator used for displacement calculations. + final Random _random; + + /// The intensity of jitter displacement applied to points. + /// + /// Ranges from 0.0 (no displacement) to 1.0 (maximum displacement). + final double intensity; + + /// Applies jitter displacement to the given [point] within the specified + /// bounds. + /// + /// Returns the original [point] when [intensity] is 0. Otherwise, applies + /// random displacement within the bounds defined by [maxDx] and [maxDy], + /// scaled by the [intensity] value. + /// + /// The displacement is bidirectional (positive or negative) and uses the + /// internal [Random] instance for consistent results across multiple calls. + Offset applyTo(Offset point, double maxDx, double maxDy) { + if (intensity <= 0) return point; + + final dx = (_random.nextDouble() * 2 - 1) * maxDx * intensity; + final dy = (_random.nextDouble() * 2 - 1) * maxDy * intensity; + return Offset(point.dx + dx, point.dy + dy); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/utils.dart b/packages/stream_chat_flutter/lib/src/utils/utils.dart index 34eb6503f8..f145eb5e28 100644 --- a/packages/stream_chat_flutter/lib/src/utils/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils/utils.dart @@ -1,4 +1,5 @@ export 'device_segmentation.dart'; export 'extensions.dart'; export 'helpers.dart'; +export 'jitter.dart'; export 'typedefs.dart'; diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png index dab41d6d78..18376d3c5a 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png index b80de3c983..654ccc4b6b 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png index cd98086709..6a9ea60ce7 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png index 3aea9bc2d1..e095f4de90 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png new file mode 100644 index 0000000000..91d4c7b2d5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png index 447e0c39e8..ba2975b39d 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png index 4e8db61604..9b80c0abff 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png index 5a43da106d..11d5669218 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart index c4d1961636..e63fe05246 100644 --- a/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,4 +114,270 @@ void main() { ), ), ); + + // Regression test for GitHub issue #2369 + // https://github.com/GetStream/stream-chat-flutter/issues/2369 + // + // Issue: All Users have the same Gradient Avatar color + // Problem: Users with same-length IDs were getting identical gradient colors + // Solution: Use userId.hashCode instead of length-based randomization + goldenTest( + 'GitHub issue #2369 - same-length user IDs should have different colors', + fileName: 'gradient_avatar_issue_2369', + constraints: const BoxConstraints.tightFor(width: 600, height: 1200), + builder: () => _wrapWithMaterialApp( + const AvatarComparisonTestWidget(), + ), + ); +} + +/// Custom test widget for GitHub issue #2369 avatar comparison +/// +/// This widget creates a custom themed layout without using GoldenTestGroup +/// to avoid theme conflicts with other tests in the package. +class AvatarComparisonTestWidget extends StatelessWidget { + const AvatarComparisonTestWidget({super.key}); + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'GitHub Issue #2369 - Gradient Avatar Color Fix', + style: theme.textTheme.title, + ), + const SizedBox(height: 8), + Text( + 'Users with same-length IDs should have different colors', + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(height: 16), + + // Test scenarios + _buildTestSection( + context, + title: 'Numeric IDs (5 chars) - Should show different colors', + description: 'Example IDs from the GitHub issue', + child: const AvatarComparisonRow( + users: [ + ('12133', 'User One'), // Example IDs from the issue + ('12134', 'User Two'), // These were showing same colors + ('12135', 'User Three'), // before the hashCode fix + ], + ), + ), + + const SizedBox(height: 24), + + _buildTestSection( + context, + title: 'Alphabetic IDs (5 chars) - Should show different colors', + description: 'Additional test with same-length alphabetic IDs', + child: const AvatarComparisonRow( + users: [ + ('abcde', 'User Alpha'), + ('fghij', 'User Beta'), + ('klmno', 'User Gamma'), + ], + ), + ), + + const SizedBox(height: 24), + + _buildTestSection( + context, + title: 'Mixed length IDs - For reference', + description: 'Different length IDs should always be different', + child: const AvatarComparisonRow( + users: [ + ('a', 'Short'), + ('medium123', 'Medium'), + ('verylonguser456', 'Long'), + ], + ), + ), + + const SizedBox(height: 24), + + _buildTestSection( + context, + title: 'Same user ID - Should be identical', + description: 'Consistency test - same ID should produce same colors', + child: const AvatarComparisonRow( + users: [ + ('test123', 'Same User'), + ('test123', 'Same User'), + ('test123', 'Same User'), + ], + ), + ), + ], + ); + } + + Widget _buildTestSection( + BuildContext context, { + required String title, + required String description, + required Widget child, + }) { + final theme = StreamChatTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textHighEmphasis, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: theme.textTheme.body.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorTheme.borders), + ), + child: child, + ), + ], + ); + } +} + +/// A widget that displays a row of gradient avatars for comparison testing. +/// +/// This widget is specifically designed for testing gradient avatar color +/// variations, particularly for verifying fixes to GitHub issue #2369 where +/// users with same-length IDs were getting identical colors. +/// +/// See: https://github.com/GetStream/stream-chat-flutter/issues/2369 +class AvatarComparisonRow extends StatelessWidget { + /// Creates an [AvatarComparisonRow] with the given list of users. + /// + /// The [users] parameter should contain tuples of (userId, userName) pairs + /// to be displayed as gradient avatars for visual comparison. + const AvatarComparisonRow({ + super.key, + required this.users, + this.avatarSize = 100.0, + this.spacing = 8.0, + }); + + /// List of users to display as (userId, userName) tuples + final List<(String, String)> users; + + /// Size of each avatar in logical pixels + final double avatarSize; + + /// Horizontal spacing between avatars in logical pixels + final double spacing; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: users.map((userData) { + final (userId, userName) = userData; + return Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: spacing / 2), + child: _AvatarItem( + userId: userId, + userName: userName, + avatarSize: avatarSize, + ), + ), + ); + }).toList(), + ); + } +} + +/// Individual avatar item with labels +class _AvatarItem extends StatelessWidget { + const _AvatarItem({ + required this.userId, + required this.userName, + required this.avatarSize, + }); + + final String userId; + final String userName; + final double avatarSize; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: avatarSize, + height: avatarSize, + child: StreamGradientAvatar( + name: userName, + userId: userId, + ), + ), + const SizedBox(height: 8), + Text( + userId, + style: theme.textTheme.footnoteBold.copyWith( + color: theme.colorTheme.textHighEmphasis, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + userName, + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Container( + padding: const EdgeInsets.all(16), + child: Center(child: widget), + ), + ); + }), + ), + ); } diff --git a/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart index 3899613e86..3cc769213d 100644 --- a/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart @@ -1,75 +1,41 @@ import 'package:alchemist/alchemist.dart'; +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../fakes.dart'; import '../material_app_wrapper.dart'; import '../mocks.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockClient client; late MockChannel channel; late MockChannelState channelState; - late MockMember member; - late MockUser user; - late MockMember member2; - late MockUser user2; - const methodChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity_status'); + + late final member = Member(user: User(id: 'alice', name: 'Alice')); + late final member2 = Member(user: User(id: 'bob', name: 'Bob')); setUpAll(() { client = MockClient(); channel = MockChannel(); channelState = MockChannelState(); - member = MockMember(); - user = MockUser(); - member2 = MockMember(); - user2 = MockUser(); when(() => channel.state!).thenReturn(channelState); - when(() => channelState.membersStream) - .thenAnswer((_) => Stream>.value([member, member2])); - when(() => member.user).thenReturn(user); - when(() => user.name).thenReturn('user123'); - when(() => user.id).thenReturn('123'); - when(() => member2.user).thenReturn(user2); - when(() => user2.name).thenReturn('user456'); - when(() => user2.id).thenReturn('456'); - }); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - methodChannel, - (MethodCall methodCall) async { - if (methodCall.method == 'listen') { - try { - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger - .handlePlatformMessage( - methodChannel.name, - methodChannel.codec.encodeSuccessEnvelope(['wifi']), - (_) {}, - ); - } catch (e) { - print(e); - } - } - - return null; - }, + when(() => channelState.membersStream).thenAnswer( + (_) => Stream>.value([member, member2]), ); }); - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - }); + final originalConnectivityPlatform = ConnectivityPlatform.instance; + setUp(() => ConnectivityPlatform.instance = FakeConnectivityPlatform()); + tearDown(() => ConnectivityPlatform.instance = originalConnectivityPlatform); testWidgets( - 'control test', + 'control test - verify different users are displayed', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( diff --git a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart index 0e1bd7cbd4..0518fd84fd 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart @@ -2,12 +2,20 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../fakes.dart'; import '../material_app_wrapper.dart'; import '../mocks.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final originalPathProviderPlatform = PathProviderPlatform.instance; + setUp(() => PathProviderPlatform.instance = FakePathProviderPlatform()); + tearDown(() => PathProviderPlatform.instance = originalPathProviderPlatform); + testWidgets( 'it should show basic channel information', (WidgetTester tester) async { diff --git a/packages/stream_chat_flutter/test/src/fakes.dart b/packages/stream_chat_flutter/test/src/fakes.dart index 022f0b3e36..d3d1b64f44 100644 --- a/packages/stream_chat_flutter/test/src/fakes.dart +++ b/packages/stream_chat_flutter/test/src/fakes.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -141,3 +142,17 @@ class FakeRecordPlatform extends Fake @override Future dispose(String recorderId) async {} } + +class FakeConnectivityPlatform extends Fake + with MockPlatformInterfaceMixin + implements ConnectivityPlatform { + @override + Future> checkConnectivity() { + return Future.value([ConnectivityResult.wifi]); + } + + @override + Stream> get onConnectivityChanged { + return Stream.value([ConnectivityResult.wifi]); + } +} diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png index 005eae2c96..c4fd3fbf44 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png index 3600948ad2..686c42af39 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png index b5625024ee..98fd94106a 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png index c71faa699e..3014717602 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png index 716fe34def..bd0afda1c3 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png index d0b26c7658..8585889f76 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png index 974868b683..bcec0ea2e2 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png index 8b25be5aaa..1e1bb81fac 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png index 2ef4f6913c..2ea6c58f14 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png index cc724f3300..13b50a4f37 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png index c82908a3d8..e19fe490b6 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png index 153e5cf18f..c3e3aecb33 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png differ diff --git a/packages/stream_chat_flutter/test/src/utils/jitter_test.dart b/packages/stream_chat_flutter/test/src/utils/jitter_test.dart new file mode 100644 index 0000000000..c1748ac274 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/utils/jitter_test.dart @@ -0,0 +1,193 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/utils/jitter.dart'; + +void main() { + group('Jitter', () { + test('default constructor creates jitter with specified intensity', () { + const intensity = 0.6; + final jitter = Jitter(intensity: intensity); + + expect(jitter.intensity, equals(intensity)); + }); + + test('default constructor clamps intensity to valid range', () { + final jitterNegative = Jitter(intensity: -0.5); + final jitterExcessive = Jitter(intensity: 2); + + expect(jitterNegative.intensity, equals(0.0)); + expect(jitterExcessive.intensity, equals(1.0)); + }); + + test('default constructor uses deterministic seed when provided', () { + const seed = 42; + final jitter1 = Jitter(seed: seed, intensity: 0.5); + final jitter2 = Jitter(seed: seed, intensity: 0.5); + + const point = Offset(10, 10); + const maxDx = 5.0; + const maxDy = 5.0; + + final result1 = jitter1.applyTo(point, maxDx, maxDy); + final result2 = jitter2.applyTo(point, maxDx, maxDy); + + expect(result1, equals(result2)); + }); + + test('custom constructor creates jitter with provided random instance', () { + final random = Random(123); + const intensity = 0.7; + final jitter = Jitter.custom(random: random, intensity: intensity); + + expect(jitter.intensity, equals(intensity)); + }); + + test('custom constructor clamps intensity to valid range', () { + final random = Random(); + final jitterNegative = Jitter.custom(random: random, intensity: -1); + final jitterExcessive = Jitter.custom(random: random, intensity: 3); + + expect(jitterNegative.intensity, equals(0.0)); + expect(jitterExcessive.intensity, equals(1.0)); + }); + + group('factory constructors', () { + test('none creates jitter with zero intensity', () { + final jitter = Jitter.none(); + expect(jitter.intensity, equals(0.0)); + }); + + test('light creates jitter with 0.2 intensity', () { + final jitter = Jitter.light(); + expect(jitter.intensity, equals(0.2)); + }); + + test('medium creates jitter with 0.4 intensity', () { + final jitter = Jitter.medium(); + expect(jitter.intensity, equals(0.4)); + }); + + test('heavy creates jitter with 0.6 intensity', () { + final jitter = Jitter.heavy(); + expect(jitter.intensity, equals(0.6)); + }); + + test('max creates jitter with 1.0 intensity', () { + final jitter = Jitter.max(); + expect(jitter.intensity, equals(1.0)); + }); + + test('factory constructors use deterministic seed when provided', () { + const seed = 999; + final jitter1 = Jitter.light(seed: seed); + final jitter2 = Jitter.light(seed: seed); + + const point = Offset(20, 30); + const maxDx = 10.0; + const maxDy = 15.0; + + final result1 = jitter1.applyTo(point, maxDx, maxDy); + final result2 = jitter2.applyTo(point, maxDx, maxDy); + + expect(result1, equals(result2)); + }); + }); + + group('applyTo', () { + test('returns original point when intensity is zero', () { + final jitter = Jitter.none(); + const point = Offset(5, 10); + + final result = jitter.applyTo(point, 20, 30); + + expect(result, equals(point)); + }); + + test('applies displacement when intensity is greater than zero', () { + final jitter = Jitter.medium(seed: 42); + const point = Offset(100, 200); + + final result = jitter.applyTo(point, 50, 100); + + expect(result, isNot(equals(point))); + }); + + test('displacement is within expected bounds', () { + final jitter = Jitter.max(seed: 123); + const point = Offset(50, 50); + const maxDx = 20.0; + const maxDy = 30.0; + + final result = jitter.applyTo(point, maxDx, maxDy); + + // With max intensity (1), displacement should be within [-maxDx, maxDx] + expect(result.dx, greaterThanOrEqualTo(point.dx - maxDx)); + expect(result.dx, lessThanOrEqualTo(point.dx + maxDx)); + expect(result.dy, greaterThanOrEqualTo(point.dy - maxDy)); + expect(result.dy, lessThanOrEqualTo(point.dy + maxDy)); + }); + + test('produces consistent results with same seed', () { + const seed = 456; + final jitter1 = Jitter(seed: seed, intensity: 0.8); + final jitter2 = Jitter(seed: seed, intensity: 0.8); + + const point = Offset(15, 25); + const maxDx = 8.0; + const maxDy = 12.0; + + final result1 = jitter1.applyTo(point, maxDx, maxDy); + final result2 = jitter2.applyTo(point, maxDx, maxDy); + + expect(result1, equals(result2)); + }); + + test('produces different results with different seeds', () { + final jitter1 = Jitter(seed: 111, intensity: 0.5); + final jitter2 = Jitter(seed: 222, intensity: 0.5); + + const point = Offset(40, 60); + const maxDx = 15.0; + const maxDy = 20.0; + + final result1 = jitter1.applyTo(point, maxDx, maxDy); + final result2 = jitter2.applyTo(point, maxDx, maxDy); + + expect(result1, isNot(equals(result2))); + }); + + test('intensity affects displacement magnitude', () { + const seed = 789; + final lightJitter = Jitter(seed: seed, intensity: 0.1); + final heavyJitter = Jitter(seed: seed, intensity: 0.9); + + const point = Offset(100, 100); + const maxDx = 50.0; + const maxDy = 50.0; + + final lightResult = lightJitter.applyTo(point, maxDx, maxDy); + final heavyResult = heavyJitter.applyTo(point, maxDx, maxDy); + + final lightDistance = (lightResult - point).distance; + final heavyDistance = (heavyResult - point).distance; + + // Heavy jitter should generally produce larger displacement + // Note: This is probabilistic, but with the same seed the pattern holds + expect(heavyDistance, greaterThan(lightDistance)); + }); + + test('handles edge cases gracefully', () { + final jitter = Jitter.medium(); + + // Test with zero bounds + final resultZero = jitter.applyTo(const Offset(10, 10), 0, 0); + expect(resultZero, equals(const Offset(10, 10))); + + // Test with negative point coordinates + final resultNegative = jitter.applyTo(const Offset(-5, -8), 10, 15); + expect(resultNegative, isA()); + }); + }); + }); +}