From 3da85398f58924f647d506e218c6bd91f99e4921 Mon Sep 17 00:00:00 2001 From: E-m-i-n-e-n-c-e Date: Wed, 15 Jan 2025 23:58:29 +0530 Subject: [PATCH] Added modal bottom sheet that shows list of users and their reactions on long pressing reactions --- lib/widgets/emoji_reaction.dart | 26 +- lib/widgets/reaction_users_sheet.dart | 403 ++++++++++++ test/widgets/reaction_users_sheet_test.dart | 690 ++++++++++++++++++++ 3 files changed, 1118 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/reaction_users_sheet.dart create mode 100644 test/widgets/reaction_users_sheet_test.dart diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 98147a54d7..6ed514cdf2 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -10,6 +10,7 @@ import 'color.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'reaction_users_sheet.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -120,6 +121,22 @@ class ReactionChipsList extends StatelessWidget { final int messageId; final Reactions reactions; + void showReactedUsers(BuildContext context, ReactionWithVotes selectedReaction) { + final store = PerAccountStoreWidget.of(context); + + showModalBottomSheet( + context: context, + builder: (BuildContext context) => PerAccountStoreWidget( + accountId: store.accountId, + child: ReactionUsersSheet( + reactions: reactions, + initialSelectedReaction: selectedReaction, + store: store, + ), + ), + ); + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); @@ -129,7 +146,9 @@ class ReactionChipsList extends StatelessWidget { return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: reactions.aggregated.map((reactionVotes) => ReactionChip( showName: showNames, - messageId: messageId, reactionWithVotes: reactionVotes), + messageId: messageId, reactionWithVotes: reactionVotes, + showReactedUsers: showReactedUsers, + ), ).toList()); } } @@ -138,12 +157,14 @@ class ReactionChip extends StatelessWidget { final bool showName; final int messageId; final ReactionWithVotes reactionWithVotes; + final void Function(BuildContext, ReactionWithVotes) showReactedUsers; const ReactionChip({ super.key, required this.showName, required this.messageId, required this.reactionWithVotes, + required this.showReactedUsers, }); @override @@ -214,6 +235,9 @@ class ReactionChip extends StatelessWidget { emojiName: emojiName, ); }, + onLongPress: () { + showReactedUsers(context, reactionWithVotes); + }, child: Padding( // 1px of this padding accounts for the border, which Flutter // just paints without changing size. diff --git a/lib/widgets/reaction_users_sheet.dart b/lib/widgets/reaction_users_sheet.dart new file mode 100644 index 0000000000..c51ad2398e --- /dev/null +++ b/lib/widgets/reaction_users_sheet.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/emoji.dart'; +import '../model/store.dart'; +import 'content.dart'; +import 'emoji.dart'; +import 'emoji_reaction.dart'; +import 'profile.dart'; +import 'text.dart'; +import 'theme.dart'; + +class ReactionUsersSheet extends StatefulWidget { + const ReactionUsersSheet({ + super.key, + required this.reactions, + required this.initialSelectedReaction, + required this.store, + }); + + final Reactions reactions; + final ReactionWithVotes initialSelectedReaction; + final PerAccountStore store; + + @override + State createState() => _ReactionUsersSheetState(); +} + +class _ReactionUsersSheetState extends State { + late ReactionWithVotes? _selectedReaction; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _selectedReaction = widget.initialSelectedReaction; + widget.store.addListener(_onStoreChanged); + // Schedule scroll after build + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedEmoji(); + }); + } + + @override + void dispose() { + widget.store.removeListener(_onStoreChanged); + _scrollController.dispose(); + super.dispose(); + } + + void _onStoreChanged() { + setState(() { + // Rebuild the widget when store changes + }); + } + + void _scrollToSelectedEmoji() { + if (_selectedReaction == null) return; + + // Find the index of the selected reaction + final index = widget.reactions.aggregated.indexOf(_selectedReaction!); + if (index == -1) return; + + // Calculate approximate position and size of the emoji button + const buttonWidth = 100.0; // Approximate width of each button including padding + final scrollPosition = index * buttonWidth; + + // Check if the button is already visible + final viewportStart = _scrollController.offset; + final viewportEnd = viewportStart + _scrollController.position.viewportDimension; + + // If button is already in view, don't scroll + if (scrollPosition >= viewportStart && scrollPosition + buttonWidth <= viewportEnd) { + return; + } + + // If not in view, animate to bring it into view + _scrollController.animateTo( + scrollPosition, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + Widget _getEmojiWidget(ReactionWithVotes reaction) { + final emojiDisplay = widget.store.emojiDisplayFor( + emojiType: reaction.reactionType, + emojiCode: reaction.emojiCode, + emojiName: reaction.emojiName, + ).resolve(widget.store.userSettings); + + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => _UnicodeEmoji( + emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => _ImageEmoji( + emojiDisplay: emojiDisplay, emojiName: reaction.emojiName, selected: false), + TextEmojiDisplay() => _TextEmoji( + emojiDisplay: emojiDisplay, selected: false), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: emoji, + ); + } + + Widget _buildEmojiButton(ReactionWithVotes reaction) { + final isSelected = _selectedReaction == reaction; + final brightness = Theme.of(context).brightness; + final isDark = brightness == Brightness.dark; + final designVariables = DesignVariables.of(context); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + setState(() { + _selectedReaction = reaction; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? Colors.black : Colors.white) + : Colors.transparent, + borderRadius: BorderRadius.circular(13), + border: isSelected ? Border.all( + color: isDark ? Colors.white.withValues(alpha: 0.2) : Colors.grey.shade300, + width: 1, + ) : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center(child: _getEmojiWidget(reaction)), + const SizedBox(height: 0.5), + Text( + reaction.userIds.length.toString(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? designVariables.textMessage + : designVariables.textMessage.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ], + ), + ), + ), + ); + } + + + List<({String name, Widget emoji, int userId})> _getUserNamesWithEmojis() { + if (_selectedReaction == null) { + // Show all users when "All" is selected + final allUserReactions = <({String name, Widget emoji, int userId})>[]; + + for (final reaction in widget.reactions.aggregated) { + // Add each user-reaction combination separately + for (final userId in reaction.userIds) { + allUserReactions.add(( + name: widget.store.users[userId]?.fullName ?? '(unknown user)', + emoji: _getEmojiWidget(reaction), + userId: userId, + )); + } + } + + // Sort by name to group the same user's reactions together + return allUserReactions..sort((a, b) => a.name.compareTo(b.name)); + } else { + // Show users for selected reaction + return _selectedReaction!.userIds.map((userId) => ( + name: widget.store.users[userId]?.fullName ?? '(unknown user)', + emoji: _getEmojiWidget(_selectedReaction!), + userId: userId, + )).toList()..sort((a, b) => a.name.compareTo(b.name)); + } + } + + @override + Widget build(BuildContext context) { + final users = _getUserNamesWithEmojis(); + final designVariables = DesignVariables.of(context); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...widget.reactions.aggregated.map((reaction) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _buildEmojiButton(reaction), + )), + ], + ), + ), + ), + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) => InkWell( + onTap: () => Navigator.push(context, + ProfilePage.buildRoute(context: context, + userId: users[index].userId)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: ListTile( + leading: Stack( + children: [ + Avatar( + size: 36, + borderRadius: 4, + userId: users[index].userId, + ), + if (widget.store.users[users[index].userId]?.isActive ?? false) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all( + color: designVariables.mainBackground, + width: 1.5, + ), + ), + ), + ), + ], + ), + title: Row( + children: [ + Expanded( + child: Text( + users[index].name, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 19, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.pop(context), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return designVariables.contextMenuCancelPressedBg; + } + return Colors.transparent; + }), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: designVariables.contextMenuCancelBg, + borderRadius: BorderRadius.circular(7), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Close', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: designVariables.contextMenuCancelText, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _UnicodeEmoji extends StatelessWidget { + const _UnicodeEmoji({required this.emojiDisplay}); + + final UnicodeEmojiDisplay emojiDisplay; + + @override + Widget build(BuildContext context) { + return UnicodeEmojiWidget( + size: _squareEmojiSize, + notoColorEmojiTextSize: _notoColorEmojiTextSize, + textScaler: _squareEmojiScalerClamped(context), + emojiDisplay: emojiDisplay); + } +} + +class _ImageEmoji extends StatelessWidget { + const _ImageEmoji({ + required this.emojiDisplay, + required this.emojiName, + required this.selected, + }); + + final ImageEmojiDisplay emojiDisplay; + final String emojiName; + final bool selected; + + @override + Widget build(BuildContext context) { + return ImageEmojiWidget( + size: _squareEmojiSize, + // Unicode and text emoji get scaled; it would look weird if image emoji didn't. + textScaler: _squareEmojiScalerClamped(context), + emojiDisplay: emojiDisplay, + errorBuilder: (context, _, __) => _TextEmoji( + emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), + ); + } +} + +class _TextEmoji extends StatelessWidget { + const _TextEmoji({required this.emojiDisplay, required this.selected}); + + final TextEmojiDisplay emojiDisplay; + final bool selected; + + @override + Widget build(BuildContext context) { + final emojiName = emojiDisplay.emojiName; + + // Encourage line breaks before "_" (common in these), but try not + // to leave a colon alone on a line. See: + // + final text = ':\ufeff${emojiName.replaceAll('_', '\u200b_')}\ufeff:'; + + final reactionTheme = EmojiReactionTheme.of(context); + return Text( + textAlign: TextAlign.end, + textScaler: _textEmojiScalerClamped(context), + textWidthBasis: TextWidthBasis.longestLine, + style: TextStyle( + fontSize: 14 * 0.8, + height: 1, // to be denser when we have to wrap + color: selected ? reactionTheme.textSelected : reactionTheme.textUnselected, + ).merge(weightVariableTextStyle(context, + wght: selected ? 600 : null)), + text); + } +} + +/// The size of a square emoji (Unicode or image). +/// +/// This is the exact size we want emojis to be rendered at. +const _squareEmojiSize = 23.0; + +/// A font size that, with Noto Color Emoji, renders at exactly our desired size. +/// This matches _squareEmojiSize since we use a height of 1.0 in the text style. +const _notoColorEmojiTextSize = 19.32; + +/// A [TextScaler] that maintains accessibility while preventing emojis from getting too large +TextScaler _squareEmojiScalerClamped(BuildContext context) => + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + +/// A [TextScaler] for text emojis that maintains accessibility while preventing excessive wrapping +TextScaler _textEmojiScalerClamped(BuildContext context) => + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + diff --git a/test/widgets/reaction_users_sheet_test.dart b/test/widgets/reaction_users_sheet_test.dart new file mode 100644 index 0000000000..4f829762ed --- /dev/null +++ b/test/widgets/reaction_users_sheet_test.dart @@ -0,0 +1,690 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/reaction_users_sheet.dart'; +import 'package:zulip/widgets/profile.dart'; + +import '../example_data.dart' as eg; +import '../model/test_store.dart'; +import '../model/binding.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + + Future prepare() async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + } + + Future pumpReactionUsersSheet(WidgetTester tester, { + required List reactions, + required ReactionWithVotes initialSelectedReaction, + Size? screenSize, + }) async { + await tester.binding.setSurfaceSize(screenSize ?? const Size(400, 600)); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: Material( + child: ReactionUsersSheet( + reactions: Reactions(reactions), + initialSelectedReaction: initialSelectedReaction, + store: store, + ), + ), + )); + await tester.pumpAndSettle(); + } + + group('ReactionUsersSheet', () { + testWidgets('displays emoji buttons correctly', (tester) async { + await prepare(); + final user1 = eg.user(fullName: 'User One', isActive: true); + final user2 = eg.user(fullName: 'User Two', isActive: false); + await store.addUsers([user1, user2]); + + final reaction1 = Reaction( + userId: user1.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + final reaction2 = Reaction( + userId: user2.userId, + emojiName: 'heart', + emojiCode: '2764', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [reaction1, reaction2]; + + final selectedReaction = ReactionWithVotes.empty(reaction1) + ..userIds.add(user1.userId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Verify emoji buttons are displayed + expect(find.text('1'), findsNWidgets(2)); // Count for both emojis + }); + + testWidgets('displays user list correctly', (tester) async { + await prepare(); + final user1 = eg.user(fullName: 'User One', isActive: true); + final user2 = eg.user(fullName: 'User Two', isActive: false); + await store.addUsers([user1, user2]); + + final reaction = Reaction( + userId: user1.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [ + reaction, + Reaction( + userId: user2.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ), + ]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.addAll([user1.userId, user2.userId]); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Verify user names are displayed + expect(find.text('User One'), findsOneWidget); + expect(find.text('User Two'), findsOneWidget); + }); + + testWidgets('handles unknown users gracefully', (tester) async { + await prepare(); + const unknownUserId = 999; + + final reaction = Reaction( + userId: unknownUserId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [reaction]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.add(unknownUserId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Verify unknown user is displayed with fallback text + expect(find.text('(unknown user)'), findsOneWidget); + }); + + testWidgets('navigates to user profile on tap', (tester) async { + await prepare(); + final user = eg.user(fullName: 'Test User', isActive: true); + await store.addUsers([user]); + + final reaction = Reaction( + userId: user.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [reaction]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.add(user.userId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Tap on user name + await tester.tap(find.text('Test User')); + await tester.pumpAndSettle(); + + // Verify navigation to profile page + expect(find.byType(ProfilePage), findsOneWidget); + }); + + testWidgets('switches between reactions', (tester) async { + await prepare(); + final user1 = eg.user(fullName: 'User One', isActive: true); + final user2 = eg.user(fullName: 'User Two', isActive: false); + await store.addUsers([user1, user2]); + + final reaction1 = Reaction( + userId: user1.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + final reaction2 = Reaction( + userId: user2.userId, + emojiName: 'heart', + emojiCode: '2764', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [reaction1, reaction2]; + + final selectedReaction = ReactionWithVotes.empty(reaction1) + ..userIds.add(user1.userId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Initially should show User One + expect(find.text('User One'), findsOneWidget); + expect(find.text('User Two'), findsNothing); + + // Tap second reaction + await tester.tap(find.text('1').last); + await tester.pumpAndSettle(); + + // Should now show User Two + expect(find.text('User One'), findsNothing); + expect(find.text('User Two'), findsOneWidget); + }); + + testWidgets('displays online status indicator correctly', (tester) async { + await prepare(); + final onlineUser = eg.user(fullName: 'Online User', isActive: true); + final offlineUser = eg.user(fullName: 'Offline User', isActive: false); + await store.addUsers([onlineUser, offlineUser]); + + final reaction = Reaction( + userId: onlineUser.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [ + reaction, + Reaction( + userId: offlineUser.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ), + ]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.addAll([onlineUser.userId, offlineUser.userId]); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Find the green dot indicators + final greenDots = find.byWidgetPredicate((widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == Colors.green); + + // Should find exactly one green dot (for the online user) + expect(greenDots, findsOneWidget); + + // Verify the green dot is associated with the online user + final onlineUserListItem = find.ancestor( + of: find.text('Online User'), + matching: find.byType(ListTile), + ); + expect( + find.descendant( + of: onlineUserListItem, + matching: greenDots, + ), + findsOneWidget, + ); + + // Verify the offline user doesn't have a green dot + final offlineUserListItem = find.ancestor( + of: find.text('Offline User'), + matching: find.byType(ListTile), + ); + expect( + find.descendant( + of: offlineUserListItem, + matching: greenDots, + ), + findsNothing, + ); + + // Test status update + await store.handleEvent(RealmUserUpdateEvent( + id: 1, + userId: offlineUser.userId, + isActive: true, + )); + await tester.pump(); + + // Should now find two green dots + expect(greenDots, findsNWidgets(2)); + + // Make the online user offline + await store.handleEvent(RealmUserUpdateEvent( + id: 2, + userId: onlineUser.userId, + isActive: false, + )); + await tester.pump(); + + // Should now find one green dot again + expect(greenDots, findsOneWidget); + }); + + testWidgets('handles horizontal overflow with many reactions', (tester) async { + await prepare(); + final users = List.generate(20, (i) => eg.user( + fullName: 'User $i', + isActive: i % 2 == 0, // Alternate between online and offline + )); + await store.addUsers(users); + + // Create 20 different reactions + final reactions = List.generate(20, (i) => Reaction( + userId: users[i].userId, + emojiName: 'emoji_$i', + emojiCode: '1f${600 + i}', + reactionType: ReactionType.unicodeEmoji, + )); + + final selectedReaction = ReactionWithVotes.empty(reactions[0]) + ..userIds.add(users[0].userId); + + // Use a narrow screen size to force horizontal scrolling + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + screenSize: const Size(300, 600), + ); + + // Verify that horizontal scrolling works + final firstReactionFinder = find.text('1').first; + final lastReactionFinder = find.text('1').last; + + // Get the initial position of the first reaction + final firstReactionInitialRect = tester.getRect(firstReactionFinder); + + // Scroll to the right + await tester.dragFrom( + tester.getCenter(find.byType(SingleChildScrollView)), + const Offset(-300, 0), + ); + await tester.pumpAndSettle(); + + // Verify that the first reaction has moved off screen + final firstReactionFinalRect = tester.getRect(firstReactionFinder); + expect(firstReactionFinalRect.left, lessThan(firstReactionInitialRect.left)); + + // Verify that we can see the last reaction + expect(tester.getRect(lastReactionFinder).right, isPositive); + }); + + testWidgets('handles vertical overflow with many users', (tester) async { + await prepare(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + // Create 50 users to ensure vertical scrolling + final users = List.generate(50, (i) => eg.user(fullName: 'User $i')); + await store.addUsers(users); + + final reaction = Reaction( + userId: users[0].userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + // Add all users to the same reaction + final selectedReaction = ReactionWithVotes.empty(reaction); + for (final user in users) { + selectedReaction.userIds.add(user.userId); + } + + // Build the widget with a constrained height + await tester.pumpWidget( + TestZulipApp( + accountId: eg.selfAccount.id, + child: Material( + child: SizedBox( + height: 300, // Constrain height to force scrolling + child: ReactionUsersSheet( + reactions: Reactions([reaction]), + initialSelectedReaction: selectedReaction, + store: store, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify initial state + expect(find.text('User 0'), findsOneWidget); + expect(find.text('User 49'), findsNothing); + + // Find the ListView + final listViewFinder = find.byType(ListView); + expect(listViewFinder, findsOneWidget); + + // Scroll down using fling + await tester.fling(listViewFinder, const Offset(0, -500), 10000); + await tester.pumpAndSettle(); + + // After scrolling down, verify that we can see users at the bottom + expect(find.text('User 0'), findsNothing); + + // Look for any users from 40-49 to verify we scrolled far enough + bool foundLaterUser = false; + for (int i = 40; i < 50; i++) { + if (find.text('User $i').evaluate().isNotEmpty) { + foundLaterUser = true; + break; + } + } + expect(foundLaterUser, isTrue, reason: 'Should find at least one user from 40-49 after scrolling down'); + + // Scroll back to top using fling + await tester.fling(listViewFinder, const Offset(0, 500), 10000); + await tester.pumpAndSettle(); + + // Verify we're back at the top + expect(find.text('User 0'), findsOneWidget); + + // Verify no users from the bottom are visible + bool foundBottomUser = false; + for (int i = 40; i < 50; i++) { + if (find.text('User $i').evaluate().isNotEmpty) { + foundBottomUser = true; + break; + } + } + expect(foundBottomUser, isFalse, reason: 'Should not find any users from 40-49 after scrolling back up'); + }); + + testWidgets('handles long user names without overflow', (tester) async { + await prepare(); + final user = eg.user(fullName: 'User with a very very very very very long name that might cause overflow issues'); + await store.addUsers([user]); + + final reaction = Reaction( + userId: user.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.add(user.userId); + + await pumpReactionUsersSheet( + tester, + reactions: [reaction], + initialSelectedReaction: selectedReaction, + screenSize: const Size(300, 600), // Narrow screen to test overflow handling + ); + + // Verify the long name is displayed + expect(find.text('User with a very very very very very long name that might cause overflow issues'), findsOneWidget); + + // Verify no overflow errors in the console + expect(tester.takeException(), isNull); + }); + + testWidgets('displays different types of emojis correctly', (tester) async { + await prepare(); + final user = eg.user(fullName: 'Test User'); + await store.addUsers([user]); + + // Unicode emoji reaction + final unicodeReaction = Reaction( + userId: user.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + // Image/custom emoji reaction + final imageReaction = Reaction( + userId: user.userId, + emojiName: 'zulip', + emojiCode: 'zulip', + reactionType: ReactionType.realmEmoji, + ); + + // Text emoji reaction (using zulip text emoji format) + final textReaction = Reaction( + userId: user.userId, + emojiName: 'octopus', + emojiCode: 'octopus', + reactionType: ReactionType.zulipExtraEmoji, + ); + + final reactions = [unicodeReaction, imageReaction, textReaction]; + final selectedReaction = ReactionWithVotes.empty(unicodeReaction) + ..userIds.add(user.userId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Verify emoji buttons are displayed + expect(find.text('1'), findsNWidgets(3)); // Each reaction has 1 user + + // Test switching between reactions and verify user display + // Initially unicode emoji is selected + expect(find.text('Test User'), findsOneWidget); + + // Switch to image emoji + await tester.tap(find.text('1').at(1)); + await tester.pumpAndSettle(); + expect(find.text('Test User'), findsOneWidget); + + // Switch to text emoji + await tester.tap(find.text('1').at(2)); + await tester.pumpAndSettle(); + expect(find.text('Test User'), findsOneWidget); + + // Switch back to unicode emoji + await tester.tap(find.text('1').first); + await tester.pumpAndSettle(); + expect(find.text('Test User'), findsOneWidget); + + // Verify no errors in the console + expect(tester.takeException(), isNull); + }); + + testWidgets('displays online status indicator correctly', (tester) async { + await prepare(); + final onlineUser = eg.user(fullName: 'Online User', isActive: true); + final offlineUser = eg.user(fullName: 'Offline User', isActive: false); + await store.addUsers([onlineUser, offlineUser]); + + final reaction = Reaction( + userId: onlineUser.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [ + reaction, + Reaction( + userId: offlineUser.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ), + ]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.addAll([onlineUser.userId, offlineUser.userId]); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Find the green dot indicators + final greenDots = find.byWidgetPredicate((widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == Colors.green); + + // Should find exactly one green dot (for the online user) + expect(greenDots, findsOneWidget); + + // Verify the green dot is associated with the online user + final onlineUserListItem = find.ancestor( + of: find.text('Online User'), + matching: find.byType(ListTile), + ); + expect( + find.descendant( + of: onlineUserListItem, + matching: greenDots, + ), + findsOneWidget, + ); + + // Verify the offline user doesn't have a green dot + final offlineUserListItem = find.ancestor( + of: find.text('Offline User'), + matching: find.byType(ListTile), + ); + expect( + find.descendant( + of: offlineUserListItem, + matching: greenDots, + ), + findsNothing, + ); + + // Test status update + await store.handleEvent(RealmUserUpdateEvent( + id: 1, + userId: offlineUser.userId, + isActive: true, + )); + await tester.pump(); + + // Should now find two green dots + expect(greenDots, findsNWidgets(2)); + + // Make the online user offline + await store.handleEvent(RealmUserUpdateEvent( + id: 2, + userId: onlineUser.userId, + isActive: false, + )); + await tester.pump(); + + // Should now find one green dot again + expect(greenDots, findsOneWidget); + }); + + testWidgets('updates online status when user status changes', (tester) async { + await prepare(); + final user = eg.user(fullName: 'Test User', isActive: false); + await store.addUsers([user]); + + final reaction = Reaction( + userId: user.userId, + emojiName: 'smile', + emojiCode: '1f642', + reactionType: ReactionType.unicodeEmoji, + ); + + final reactions = [reaction]; + + final selectedReaction = ReactionWithVotes.empty(reaction) + ..userIds.add(user.userId); + + await pumpReactionUsersSheet( + tester, + reactions: reactions, + initialSelectedReaction: selectedReaction, + ); + + // Initially no green dot + expect( + find.byWidgetPredicate((widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == Colors.green), + findsNothing, + ); + + // Change user status to online + await store.handleEvent(RealmUserUpdateEvent( + id: 1, + userId: user.userId, + isActive: true, + )); + await tester.pump(); + + // Should now show green dot + expect( + find.byWidgetPredicate((widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == Colors.green), + findsOneWidget, + ); + + // Change user status back to offline + await store.handleEvent(RealmUserUpdateEvent( + id: 2, + userId: user.userId, + isActive: false, + )); + await tester.pump(); + + // Green dot should be gone + expect( + find.byWidgetPredicate((widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).color == Colors.green), + findsNothing, + ); + }); + }); +} \ No newline at end of file