From c46fbaa95ce720216b60675c4cf8c4e1e4e5212b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 17 Jul 2025 12:17:19 +0430 Subject: [PATCH 1/9] test store [nfc]: Make changeUserStatuses take a Map instead of a Record --- test/model/test_store.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/model/test_store.dart b/test/model/test_store.dart index e77b5fc2a0..18e41bcfb5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -275,8 +275,8 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); } - Future changeUserStatuses(List<(int userId, UserStatusChange change)> changes) async { - for (final (userId, change) in changes) { + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { await changeUserStatus(userId, change); } } From 00088dbf248dbe80cd00b80af07ed7b823bbffdc Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:31:25 +0430 Subject: [PATCH 2/9] msglist: Show user status emoji --- lib/widgets/message_list.dart | 2 + test/widgets/message_list_test.dart | 69 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 26021e108e..3cc767f0c5 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1958,6 +1958,8 @@ class SenderRow extends StatelessWidget { : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 54c714b34d..27da0b0f7f 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/message.dart'; @@ -1772,6 +1773,74 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.text('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.text('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.text('Busy')).findsNothing(); + }); + }); + group('Muted sender', () { void checkMessage(Message message, {required bool expectIsMuted}) { final mutedLabel = 'Muted user'; From 8cf7bf73ace8edfd60767a759134bf889b9ef3f5 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:32:27 +0430 Subject: [PATCH 3/9] recent-dms: Show user status emoji in recent DMs page Status emojis are only shown for self-1:1 and 1:1 conversation items. They're ignored for group conversations as that's what the Web does. --- lib/widgets/recent_dm_conversations.dart | 24 ++++-- .../widgets/recent_dm_conversations_test.dart | 84 ++++++++++++++++++- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 5526557589..96ecfdbef4 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -104,23 +104,29 @@ class RecentDmConversationsItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); - final String title; + final InlineSpan title; final Widget avatar; int? userIdForPresence; switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: - title = store.selfUser.fullName; + title = TextSpan(text: store.selfUser.fullName, children: [ + UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: - title = store.userDisplayName(otherUserId); + title = TextSpan(text: store.userDisplayName(otherUserId), children: [ + UserStatusEmoji.asWidgetSpan(userId: otherUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context)), + ]); avatar = AvatarImage(userId: otherUserId, size: _avatarSize); userIdForPresence = otherUserId; default: - // TODO(i18n): List formatting, like you can do in JavaScript: - // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) - // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map(store.userDisplayName) - .join(', '); + title = TextSpan( + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + text: narrow.otherRecipientIds.map(store.userDisplayName).join(', ')); avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( child: Icon(color: designVariables.avatarPlaceholderIcon, @@ -148,7 +154,7 @@ class RecentDmConversationsItem extends StatelessWidget { const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( + child: Text.rich( style: TextStyle( fontSize: 17, height: (20 / 17), diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 3eb49f2ca8..b0e94128cc 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; @@ -24,6 +26,8 @@ import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, @@ -34,7 +38,7 @@ Future setupPage(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); for (final user in users) { @@ -176,7 +180,7 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), + matching: find.textContaining(expectedText), )); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); @@ -186,6 +190,16 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.handleEvent(UpdateMessageFlagsAddEvent( @@ -231,6 +245,31 @@ void main() { checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -291,6 +330,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -379,6 +445,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]); From a38b10b593f3dff0de7151d15ee5da3a704a0b3a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:37:00 +0430 Subject: [PATCH 4/9] new-dm: Show user status emoji --- lib/widgets/new_dm_sheet.dart | 8 +- test/widgets/new_dm_sheet_test.dart | 128 ++++++++++++++++++++++------ 2 files changed, 108 insertions(+), 28 deletions(-) diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 56f098790f..e67b62e382 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -317,6 +317,8 @@ class _SelectedUserChip extends StatelessWidget { fontSize: 16, height: 16 / 16, color: designVariables.labelMenuButton)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), ]))); } } @@ -415,7 +417,11 @@ class _NewDmUserListItem extends StatelessWidget { Avatar(userId: userId, size: 32, borderRadius: 3), SizedBox(width: 8), Expanded( - child: Text(store.userDisplayName(userId), + child: Text.rich( + TextSpan(text: store.userDisplayName(userId), children: [ + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context)), + ]), style: TextStyle( fontSize: 17, height: 19 / 17, diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index fc9567d78d..86fe2bfaeb 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; @@ -19,6 +21,8 @@ import '../model/test_store.dart'; import '../test_navigation.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupSheet(WidgetTester tester, { required List users, List? mutedUserIds, @@ -30,7 +34,7 @@ Future setupSheet(WidgetTester tester, { ..onPushed = (route, _) => lastPushedRoute = route; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users); if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); @@ -65,7 +69,8 @@ void main() { } Finder findUserTile(User user) => - find.widgetWithText(InkWell, user.fullName).first; + find.ancestor(of: find.textContaining(user.fullName), + matching: find.byType(InkWell)).first; Finder findUserChip(User user) { final findAvatar = find.byWidgetPredicate((widget) => @@ -120,23 +125,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsOne(); - check(find.text('Charlie Carter')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsOne(); + check(find.textContaining('Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.text('Someone Muted')).findsNothing(); - check(find.text('Muted user')).findsNothing(); + check(find.textContaining('Someone Muted')).findsNothing(); + check(find.textContaining('Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -146,11 +151,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -158,31 +163,31 @@ void main() { await tester.enterText(find.byType(TextField), 'Ali'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); await tester.enterText(find.byType(TextField), 'Anderson'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); await tester.enterText(find.byType(TextField), 'son'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); testWidgets('shows empty state when no users match', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Zebra'); await tester.pump(); - check(find.text('No users found')).findsOne(); - check(find.text('Alice Anderson')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('No users found')).findsOne(); + check(find.textContaining('Alice Anderson')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { @@ -252,7 +257,7 @@ void main() { await tester.tap(findUserTile(eg.selfUser)); await tester.pump(); checkUserSelected(tester, eg.selfUser, true); - check(find.text(eg.selfUser.fullName)).findsExactly(2); + check(find.textContaining(eg.selfUser.fullName)).findsExactly(2); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -264,7 +269,7 @@ void main() { final otherUser = eg.user(fullName: 'Other User'); await setupSheet(tester, users: [eg.selfUser, otherUser]); - check(find.text(eg.selfUser.fullName)).findsOne(); + check(find.textContaining(eg.selfUser.fullName)).findsOne(); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -285,6 +290,75 @@ void main() { }); }); + group('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.descendant(of: findUserChip(user), + matching: find.text('Busy'))).findsNothing(); + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(find.descendant(of: findUserTile(user), + matching: find.textContaining('Busy'))).findsNothing(); + check(findUserChip(user)).findsOne(); + check(find.descendant(of: findUserChip(user), + matching: find.text('Busy'))).findsNothing(); + }); + }); + group('navigation to DM Narrow', () { Future runAndCheck(WidgetTester tester, { required List users, From d5cfe41b608c4b6e38b718c4bfdb39dac538bc38 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 17 Jul 2025 22:01:04 +0430 Subject: [PATCH 5/9] autocomplete [nfc]: Make MentionAutocompleteItem visibleForTesting --- lib/widgets/autocomplete.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 526c7edfb1..78245a85f9 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -238,8 +238,13 @@ class ComposeAutocomplete extends AutocompleteField Date: Fri, 27 Jun 2025 02:38:42 +0430 Subject: [PATCH 6/9] autocomplete: Show user status emoji in user-mention autocomplete --- lib/widgets/autocomplete.dart | 31 ++++++++-------- test/widgets/autocomplete_test.dart | 55 ++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 78245a85f9..7445370e7a 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -277,29 +277,35 @@ class MentionAutocompleteItem extends StatelessWidget { Widget avatar; String label; + Widget? emoji; String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): avatar = Avatar(userId: userId, size: 36, borderRadius: 4); label = store.userDisplayName(userId); + emoji = UserStatusEmoji(userId: userId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)); sublabel = store.userDisplayEmail(userId); case WildcardMentionAutocompleteResult(:var wildcardOption): avatar = SizedBox.square(dimension: 36, child: const Icon(ZulipIcons.three_person, size: 24)); label = wildcardOption.canonicalString; + emoji = null; sublabel = wildcardSublabel(wildcardOption, context: context, store: store); } - final labelWidget = Text( - label, - style: TextStyle( - fontSize: 18, - height: 20 / 18, - color: designVariables.contextMenuItemLabel, - ).merge(weightVariableTextStyle(context, - wght: sublabel == null ? 500 : 600)), - overflow: TextOverflow.ellipsis, - maxLines: 1); + final labelWidget = Row(children: [ + Flexible(child: Text(label, + style: TextStyle( + fontSize: 18, + height: 20 / 18, + color: designVariables.contextMenuItemLabel, + ).merge(weightVariableTextStyle(context, + wght: sublabel == null ? 500 : 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1)), + ?emoji, + ]); final sublabelWidget = sublabel == null ? null : Text( sublabel, @@ -318,10 +324,7 @@ class MentionAutocompleteItem extends StatelessWidget { Expanded(child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - labelWidget, - if (sublabelWidget != null) sublabelWidget, - ])), + children: [labelWidget, ?sublabelWidget])), ])); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 573921b663..6d26b9a1e0 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,12 +7,14 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -202,6 +206,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('status emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.text('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('status emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.text('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } From 032110bb968c09dde22b3e5b41ab60c0c36d8fca Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 27 Jun 2025 02:39:27 +0430 Subject: [PATCH 7/9] profile: Show user status Fixes: #197 --- lib/widgets/profile.dart | 14 +++++++++++++- lib/widgets/theme.dart | 8 ++++++++ test/widgets/profile_test.dart | 12 ++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index b4c610b71f..6576e2a0fe 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -16,6 +16,7 @@ import 'page.dart'; import 'remote_settings.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -51,6 +52,7 @@ class ProfilePage extends StatelessWidget { final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); + final userStatus = store.getUserStatus(userId); final displayEmail = store.userDisplayEmail(userId); final items = [ Center( @@ -73,9 +75,20 @@ class ProfilePage extends StatelessWidget { ), // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), ]), textAlign: TextAlign.center, style: nameStyle), + if (userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -83,7 +96,6 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..8e70f28e3c 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -213,6 +213,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -309,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -388,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -480,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -559,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -637,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -722,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 61a85cb63e..ab243c45bd 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; @@ -96,9 +97,20 @@ void main() { deliveryEmail: 'testuser@example.com'); await setupPage(tester, users: [user], pageUserId: user.userId); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(because: 'find user status emoji', statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(because: 'find user status text', find.text('Busy')).findsOne(); check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); }); From 9aa3c62935f44200016049f329c96a4e4a318a06 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2025 17:04:13 -0700 Subject: [PATCH 8/9] test: Add find.text analogue with includePlaceholders option The bulk of this code is copied verbatim from the implementation of `find.text`, in `package:flutter_test/src/finders.dart`. The new logic is just the includePlaceholders flag, and passing it down to where InlineSpan.toPlainText gets called. The latter already has a corresponding flag, which does the rest. --- test/widgets/finders.dart | 102 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/widgets/finders.dart diff --git a/test/widgets/finders.dart b/test/widgets/finders.dart new file mode 100644 index 0000000000..fe4111cdbe --- /dev/null +++ b/test/widgets/finders.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Like `find.text` from flutter_test upstream, but with +/// the `includePlaceholders` option. +/// +/// When `includePlaceholders` is true, any [PlaceholderSpan] (for example, +/// any [WidgetSpan]) in the tree will be represented as +/// an "object replacement character", U+FFFC. +/// When `includePlaceholders` is false, such spans will be omitted. +/// +/// TODO(upstream): get `find.text` to accept includePlaceholders +Finder findText(String text, { + bool findRichText = false, + bool includePlaceholders = true, + bool skipOffstage = true, +}) { + return _TextWidgetFinder(text, + findRichText: findRichText, + includePlaceholders: includePlaceholders, + skipOffstage: skipOffstage); +} + +// (Compare the implementation in `package:flutter_test/src/finders.dart`.) +abstract class _MatchTextFinder extends MatchFinder { + _MatchTextFinder({this.findRichText = false, this.includePlaceholders = true, + super.skipOffstage}); + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + final bool findRichText; + + final bool includePlaceholders; + + bool matchesText(String textToMatch); + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + if (widget is EditableText) { + return _matchesEditableText(widget); + } + + if (!findRichText) { + return _matchesNonRichText(widget); + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget); + } + + bool _matchesRichText(Widget widget) { + if (widget is RichText) { + return matchesText(widget.text.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesNonRichText(Widget widget) { + if (widget is Text) { + if (widget.data != null) { + return matchesText(widget.data!); + } + assert(widget.textSpan != null); + return matchesText(widget.textSpan!.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesEditableText(EditableText widget) { + return matchesText(widget.controller.text); + } +} + +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder(this.text, {super.findRichText, super.includePlaceholders, + super.skipOffstage}); + + final String text; + + @override + String get description => 'text "$text"'; + + @override + bool matchesText(String textToMatch) { + return textToMatch == text; + } +} From 4eca2897e5de460e250d3a7da750b6a21f9f6246 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 21 Jul 2025 17:08:17 -0700 Subject: [PATCH 9/9] (squash) Demo using the new findText and includePlaceholders --- test/widgets/new_dm_sheet_test.dart | 47 ++++++++++--------- .../widgets/recent_dm_conversations_test.dart | 6 ++- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index 86fe2bfaeb..0ca74fccdb 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -19,6 +19,7 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; +import 'finders.dart'; import 'test_app.dart'; late PerAccountStore store; @@ -125,23 +126,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.textContaining('Alice Anderson')).findsOne(); - check(find.textContaining('Bob Brown')).findsOne(); - check(find.textContaining('Charlie Carter')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.textContaining('Someone Muted')).findsNothing(); - check(find.textContaining('Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Alice'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); - check(find.textContaining('Charlie Carter')).findsNothing(); - check(find.textContaining('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -151,11 +152,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -163,31 +164,31 @@ void main() { await tester.enterText(find.byType(TextField), 'Ali'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); - check(find.textContaining('Bob Brown')).findsNothing(); - check(find.textContaining('Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); await tester.enterText(find.byType(TextField), 'Anderson'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); - check(find.textContaining('Charlie Carter')).findsNothing(); - check(find.textContaining('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); await tester.enterText(find.byType(TextField), 'son'); await tester.pump(); - check(find.textContaining('Alice Anderson')).findsOne(); - check(find.textContaining('Charlie Carter')).findsNothing(); - check(find.textContaining('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); testWidgets('shows empty state when no users match', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Zebra'); await tester.pump(); - check(find.textContaining('No users found')).findsOne(); - check(find.textContaining('Alice Anderson')).findsNothing(); - check(find.textContaining('Bob Brown')).findsNothing(); - check(find.textContaining('Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'No users found')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index b0e94128cc..a9186ad240 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -22,6 +22,7 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'content_checks.dart'; +import 'finders.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; @@ -180,8 +181,9 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.textContaining(expectedText), - )); + // The title might contain a WidgetSpan (for status emoji); exclude + // the resulting placeholder character from the text to be matched. + matching: findText(expectedText, includePlaceholders: false))); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); check(renderObject.size.height).equals(