From a78d4e06a68c76904fb49a6960c35f608812a3bc Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 21 Mar 2024 10:55:05 +0430 Subject: [PATCH] autocomplete: Add user avatars to user-mention autocompletes Fixes: #227 --- lib/widgets/autocomplete.dart | 13 +++++++-- test/widgets/autocomplete_test.dart | 43 +++++++++++++++++++++++++++-- test/widgets/content_test.dart | 31 +++++++++++---------- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index ba5dea1b84b..3e9eca255d8 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'content.dart'; import 'store.dart'; import '../model/autocomplete.dart'; import '../model/compose.dart'; @@ -119,10 +120,11 @@ class _ComposeAutocompleteState extends State with PerAccou Widget _buildItem(BuildContext _, int index) { final option = _resultsToDisplay[index]; + Widget avatar; String label; switch (option) { case UserMentionAutocompleteResult(:var userId): - // TODO(#227) avatar + avatar = Avatar(userId: userId, size: 28, borderRadius: 3); label = PerAccountStoreWidget.of(context).users[userId]!.fullName; } return InkWell( @@ -130,8 +132,13 @@ class _ComposeAutocompleteState extends State with PerAccou _onTapOption(option); }, child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text(label))); + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + avatar, + const SizedBox(width: 8), + Text(label), + ]))); } @override diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 6f540111ca9..f07ab1d25a3 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -6,6 +6,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/store.dart'; @@ -13,6 +14,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import 'content_test.dart'; /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// @@ -39,6 +41,8 @@ Future setupToComposeInput(WidgetTester tester, { messages: [message], ).toJson()); + prepareBoringImageHttpClient(); + await tester.pumpWidget( MaterialApp( localizationsDelegates: ZulipLocalizations.localizationsDelegates, @@ -65,10 +69,18 @@ void main() { TestZulipBinding.ensureInitialized(); group('ComposeAutocomplete', () { + + Finder findNetworkImage(String url) { + return find.byWidgetPredicate((widget) => + widget is Image && + widget.image is NetworkImage && + (widget.image as NetworkImage).url == url); + } + testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { - final user1 = eg.user(userId: 1, fullName: 'User One'); - final user2 = eg.user(userId: 2, fullName: 'User Two'); - final user3 = eg.user(userId: 3, fullName: 'User Three'); + final user1 = eg.user(userId: 1, fullName: 'User One', avatarUrl: 'user1.png'); + final user2 = eg.user(userId: 2, fullName: 'User Two', avatarUrl: 'user2.png'); + final user3 = eg.user(userId: 3, fullName: 'User Three', avatarUrl: 'user3.png'); final composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); @@ -77,10 +89,22 @@ void main() { await tester.enterText(composeInputFinder, 'hello @user '); await tester.enterText(composeInputFinder, 'hello @user t'); await tester.pumpAndSettle(); // async computation; options appear + // "User Two" and "User Three" appear, but not "User One" check(tester.widgetList(find.text('User One'))).isEmpty(); + final user1AvatarFinder = + findNetworkImage(store.tryResolveUrl('user1.png').toString()); + check(tester.widgetList(user1AvatarFinder)).isEmpty(); + tester.widget(find.text('User Two')); + final user2AvatarFinder = + findNetworkImage(store.tryResolveUrl('user2.png').toString()); + tester.widget(user2AvatarFinder); + tester.widget(find.text('User Three')); + final user3AvatarFinder = + findNetworkImage(store.tryResolveUrl('user3.png').toString()); + tester.widget(user3AvatarFinder); // Finishing autocomplete updates compose box; causes options to disappear await tester.tap(find.text('User Three')); @@ -88,8 +112,13 @@ void main() { check(tester.widget(composeInputFinder).controller!.text) .contains(mention(user3, users: store.users)); check(tester.widgetList(find.text('User One'))).isEmpty(); + check(tester.widgetList(user1AvatarFinder)).isEmpty(); + check(tester.widgetList(find.text('User Two'))).isEmpty(); + check(tester.widgetList(user2AvatarFinder)).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + check(tester.widgetList(user3AvatarFinder)).isEmpty(); // Then a new autocomplete intent brings up options again // TODO(#226): Remove this extra edit when this bug is fixed. @@ -97,14 +126,22 @@ void main() { await tester.enterText(composeInputFinder, 'hello @user two'); await tester.pumpAndSettle(); // async computation; options appear tester.widget(find.text('User Two')); + tester.widget(user2AvatarFinder); // Removing autocomplete intent causes options to disappear // TODO(#226): Remove one of these edits when this bug is fixed. await tester.enterText(composeInputFinder, ''); await tester.enterText(composeInputFinder, ' '); check(tester.widgetList(find.text('User One'))).isEmpty(); + check(tester.widgetList(user1AvatarFinder)).isEmpty(); + check(tester.widgetList(find.text('User Two'))).isEmpty(); + check(tester.widgetList(user2AvatarFinder)).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + check(tester.widgetList(user3AvatarFinder)).isEmpty(); + + debugNetworkImageHttpClientProvider = null; }); }); } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 00a7a1cfba9..6f0969cc8d9 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -27,6 +27,22 @@ import 'dialog_checks.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; + +/// Set [debugNetworkImageHttpClientProvider] to return a constant image. +/// +/// Returns the [FakeImageHttpClient] that handles the requests. +/// +/// The caller must set [debugNetworkImageHttpClientProvider] back to null +/// before the end of the test. +FakeImageHttpClient prepareBoringImageHttpClient() { + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + return httpClient; +} + void main() { // For testing a new content feature: // @@ -64,21 +80,6 @@ void main() { }); } - /// Set [debugNetworkImageHttpClientProvider] to return a constant image. - /// - /// Returns the [FakeImageHttpClient] that handles the requests. - /// - /// The caller must set [debugNetworkImageHttpClientProvider] back to null - /// before the end of the test. - FakeImageHttpClient prepareBoringImageHttpClient() { - final httpClient = FakeImageHttpClient(); - debugNetworkImageHttpClientProvider = () => httpClient; - httpClient.request.response - ..statusCode = HttpStatus.ok - ..content = kSolidBlueAvatar; - return httpClient; - } - group('Heading', () { testWidgets('plain h6', (tester) async { await prepareContentBare(tester,