From 37e7ecd76286555a402e00f60e7a126153a55900 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 7 Jul 2023 14:25:49 -0700 Subject: [PATCH 1/9] compose: Fix `mention` wrongly deciding to omit user ID Reading [Iterable.takeWhile] more closely than I did when writing this `mention` function in 6bd11b65f, it turns out it's not what we want here: /// Creates a lazy iterable of the leading elements satisfying [test]. Note that it says "*leading* elements". [Iterable.where] also says it gives a lazy [Iterable], without the "leading" restriction. Use that, and change the test so it would fail if the bug were re-introduced. --- lib/model/compose.dart | 2 +- test/model/compose_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/model/compose.dart b/lib/model/compose.dart index c5510583c4..cec844e162 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -184,7 +184,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { /// through all users; avoid it in performance-sensitive codepaths. String mention(User user, {bool silent = false, Map? users}) { bool includeUserId = users == null - || users.values.takeWhile((u) => u.fullName == user.fullName).take(2).length == 2; + || users.values.where((u) => u.fullName == user.fullName).take(2).length == 2; return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; } diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 57e7167515..4f22334778 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -311,7 +311,7 @@ hello }); test('`users` passed; has two users with same fullName', () { final store = eg.store(); - store.addUsers([user, eg.user(userId: 234, fullName: user.fullName)]); + store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () { From 4b81b72a6fce6f392da62b9cc930d5316056d4fe Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 23 Jun 2023 14:45:31 -0700 Subject: [PATCH 2/9] compose: Add missing `dispose` call for _mentionAutocompleteView This looks like something we just forgot to do. --- lib/widgets/compose_box.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 356a2c028f..dfbb0d4d8e 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -316,6 +316,7 @@ class _ContentInputState extends State<_ContentInput> { @override void dispose() { widget.controller.removeListener(_changed); + _mentionAutocompleteView?.dispose(); super.dispose(); } From 2c919c21322974844cb9a9ea835be26d735d6503 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 12:38:36 -0700 Subject: [PATCH 3/9] autocomplete [nfc]: Make MentionAutocompleteResult a sealed class --- lib/model/autocomplete.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index c58e639b48..86ea3aebe7 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -331,7 +331,7 @@ class AutocompleteDataCache { } } -abstract class MentionAutocompleteResult {} +sealed class MentionAutocompleteResult {} class UserMentionAutocompleteResult extends MentionAutocompleteResult { UserMentionAutocompleteResult({required this.userId}); From 1b808eb9d9412fc7593efc01d577922f24c9e3b0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 12:49:22 -0700 Subject: [PATCH 4/9] compose [nfc]: Factor out autocomplete view-model handling to new widget --- lib/widgets/autocomplete.dart | 72 +++++++++++++++++++++++++++++++++++ lib/widgets/compose_box.dart | 58 +++++++--------------------- 2 files changed, 85 insertions(+), 45 deletions(-) create mode 100644 lib/widgets/autocomplete.dart diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart new file mode 100644 index 0000000000..9eaa9838f1 --- /dev/null +++ b/lib/widgets/autocomplete.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'store.dart'; +import '../model/autocomplete.dart'; +import '../model/narrow.dart'; +import 'compose_box.dart'; + +class ComposeAutocomplete extends StatefulWidget { + const ComposeAutocomplete({ + super.key, + required this.narrow, + required this.controller, + required this.focusNode, + required this.fieldViewBuilder, + }); + + /// The message list's narrow. + final Narrow narrow; + + final ComposeContentController controller; + final FocusNode focusNode; + final WidgetBuilder fieldViewBuilder; + + @override + State createState() => _ComposeAutocompleteState(); +} + +class _ComposeAutocompleteState extends State { + MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types + + void _changed() { + final newAutocompleteIntent = widget.controller.autocompleteIntent(); + if (newAutocompleteIntent != null) { + final store = PerAccountStoreWidget.of(context); + _mentionAutocompleteView ??= MentionAutocompleteView.init( + store: store, narrow: widget.narrow); + _mentionAutocompleteView!.query = newAutocompleteIntent.query; + } else { + if (_mentionAutocompleteView != null) { + _mentionAutocompleteView!.dispose(); + _mentionAutocompleteView = null; + } + } + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_changed); + } + + @override + void didUpdateWidget(covariant ComposeAutocomplete oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_changed); + widget.controller.addListener(_changed); + } + } + + @override + void dispose() { + widget.controller.removeListener(_changed); + _mentionAutocompleteView?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.fieldViewBuilder(context); + } +} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index dfbb0d4d8e..0193f55838 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -6,10 +6,10 @@ import 'package:image_picker/image_picker.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; -import '../model/autocomplete.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import 'autocomplete.dart'; import 'dialog.dart'; import 'store.dart'; @@ -281,45 +281,6 @@ class _ContentInput extends StatefulWidget { } class _ContentInputState extends State<_ContentInput> { - MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types - - _changed() { - final newAutocompleteIntent = widget.controller.autocompleteIntent(); - if (newAutocompleteIntent != null) { - final store = PerAccountStoreWidget.of(context); - _mentionAutocompleteView ??= MentionAutocompleteView.init( - store: store, narrow: widget.narrow); - _mentionAutocompleteView!.query = newAutocompleteIntent.query; - } else { - if (_mentionAutocompleteView != null) { - _mentionAutocompleteView!.dispose(); - _mentionAutocompleteView = null; - } - } - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_changed); - } - - @override - void didUpdateWidget(covariant _ContentInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_changed); - widget.controller.addListener(_changed); - } - } - - @override - void dispose() { - widget.controller.removeListener(_changed); - _mentionAutocompleteView?.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; @@ -333,13 +294,20 @@ class _ContentInputState extends State<_ContentInput> { // TODO constrain this adaptively (i.e. not hard-coded 200) maxHeight: 200, ), - child: TextField( + child: ComposeAutocomplete( + narrow: widget.narrow, controller: widget.controller, focusNode: widget.focusNode, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration.collapsed(hintText: widget.hintText), - maxLines: null, - ))); + fieldViewBuilder: (context) { + return TextField( + controller: widget.controller, + focusNode: widget.focusNode, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration.collapsed(hintText: widget.hintText), + maxLines: null, + ); + }), + )); } } From 677d9df65f103b70dd75550b6b734620dbba1e58 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 15:53:32 -0700 Subject: [PATCH 5/9] compose [nfc]: Make _ContentInput a stateless widget It recently handed off its stateful logic to ComposeAutocomplete. --- lib/widgets/compose_box.dart | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 0193f55838..e91f8d1f2f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -263,7 +263,7 @@ class ComposeContentController extends ComposeController } } -class _ContentInput extends StatefulWidget { +class _ContentInput extends StatelessWidget { const _ContentInput({ required this.narrow, required this.controller, @@ -276,11 +276,6 @@ class _ContentInput extends StatefulWidget { final FocusNode focusNode; final String hintText; - @override - State<_ContentInput> createState() => _ContentInputState(); -} - -class _ContentInputState extends State<_ContentInput> { @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; @@ -295,15 +290,15 @@ class _ContentInputState extends State<_ContentInput> { maxHeight: 200, ), child: ComposeAutocomplete( - narrow: widget.narrow, - controller: widget.controller, - focusNode: widget.focusNode, + narrow: narrow, + controller: controller, + focusNode: focusNode, fieldViewBuilder: (context) { return TextField( - controller: widget.controller, - focusNode: widget.focusNode, + controller: controller, + focusNode: focusNode, style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration.collapsed(hintText: widget.hintText), + decoration: InputDecoration.collapsed(hintText: hintText), maxLines: null, ); }), From de3255be288654d9a749f7a8f8a240bd645743f6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 12:50:36 -0700 Subject: [PATCH 6/9] autocomplete [nfc]: Rename a private method to be more specific We're about to write a listener on a MentionAutocompleteView (the view-model), and we won't want that one to get confused with this. --- lib/widgets/autocomplete.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 9eaa9838f1..0d56805bcf 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -28,7 +28,7 @@ class ComposeAutocomplete extends StatefulWidget { class _ComposeAutocompleteState extends State { MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types - void _changed() { + void _composeContentChanged() { final newAutocompleteIntent = widget.controller.autocompleteIntent(); if (newAutocompleteIntent != null) { final store = PerAccountStoreWidget.of(context); @@ -46,21 +46,21 @@ class _ComposeAutocompleteState extends State { @override void initState() { super.initState(); - widget.controller.addListener(_changed); + widget.controller.addListener(_composeContentChanged); } @override void didUpdateWidget(covariant ComposeAutocomplete oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_changed); - widget.controller.addListener(_changed); + oldWidget.controller.removeListener(_composeContentChanged); + widget.controller.addListener(_composeContentChanged); } } @override void dispose() { - widget.controller.removeListener(_changed); + widget.controller.removeListener(_composeContentChanged); _mentionAutocompleteView?.dispose(); super.dispose(); } From 6a9139a3b866693a11050893842e2b7e6d52fa95 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 12:52:00 -0700 Subject: [PATCH 7/9] autocomplete [nfc]: Shorten name of widget's MentionAutocompleteView This widget is all about managing autocompletes, so there isn't another kind of view model that this is likely to get confused with. --- lib/widgets/autocomplete.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 0d56805bcf..cd77dc9402 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -26,19 +26,19 @@ class ComposeAutocomplete extends StatefulWidget { } class _ComposeAutocompleteState extends State { - MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types + MentionAutocompleteView? _viewModel; // TODO different autocomplete view types void _composeContentChanged() { final newAutocompleteIntent = widget.controller.autocompleteIntent(); if (newAutocompleteIntent != null) { final store = PerAccountStoreWidget.of(context); - _mentionAutocompleteView ??= MentionAutocompleteView.init( + _viewModel ??= MentionAutocompleteView.init( store: store, narrow: widget.narrow); - _mentionAutocompleteView!.query = newAutocompleteIntent.query; + _viewModel!.query = newAutocompleteIntent.query; } else { - if (_mentionAutocompleteView != null) { - _mentionAutocompleteView!.dispose(); - _mentionAutocompleteView = null; + if (_viewModel != null) { + _viewModel!.dispose(); + _viewModel = null; } } } @@ -61,7 +61,7 @@ class _ComposeAutocompleteState extends State { @override void dispose() { widget.controller.removeListener(_composeContentChanged); - _mentionAutocompleteView?.dispose(); + _viewModel?.dispose(); super.dispose(); } From a8be6bd52d4347c9219c32b99d439bd002a87d33 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 28 Jun 2023 14:34:09 -0700 Subject: [PATCH 8/9] autocomplete [nfc]: Add TODO about sorting results This *looks* like a likely-looking place where we might add this logic, but perhaps it belongs somewhere else? We'll take a closer look later. --- lib/model/autocomplete.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 86ea3aebe7..f06fb5bc85 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -267,7 +267,7 @@ class MentionAutocompleteView extends ChangeNotifier { } } } - return results; + return results; // TODO sort for most relevant first } } From 234b65d5a2d7dec2ec708313fa5d23f29836124e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 23 Jun 2023 15:05:33 -0700 Subject: [PATCH 9/9] autocomplete: Add basic UI for user-mention autocompletes The UI for now is kept simple; the optionsViewBuilder closely follows the one in the Material library's `Autocomplete`, which thinly wraps `RawAutocomplete`: https://api.flutter.dev/flutter/material/Autocomplete-class.html Fixes: #49 Fixes: #129 --- lib/widgets/autocomplete.dart | 112 ++++++++++++++++++++++++++-- test/widgets/autocomplete_test.dart | 107 ++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 test/widgets/autocomplete_test.dart diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index cd77dc9402..74bd341ab3 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'store.dart'; import '../model/autocomplete.dart'; +import '../model/compose.dart'; import '../model/narrow.dart'; import 'compose_box.dart'; @@ -32,13 +33,14 @@ class _ComposeAutocompleteState extends State { final newAutocompleteIntent = widget.controller.autocompleteIntent(); if (newAutocompleteIntent != null) { final store = PerAccountStoreWidget.of(context); - _viewModel ??= MentionAutocompleteView.init( - store: store, narrow: widget.narrow); + _viewModel ??= MentionAutocompleteView.init(store: store, narrow: widget.narrow) + ..addListener(_viewModelChanged); _viewModel!.query = newAutocompleteIntent.query; } else { if (_viewModel != null) { - _viewModel!.dispose(); + _viewModel!.dispose(); // removes our listener _viewModel = null; + _resultsToDisplay = []; } } } @@ -61,12 +63,112 @@ class _ComposeAutocompleteState extends State { @override void dispose() { widget.controller.removeListener(_composeContentChanged); - _viewModel?.dispose(); + _viewModel?.dispose(); // removes our listener super.dispose(); } + List _resultsToDisplay = []; + + void _viewModelChanged() { + setState(() { + _resultsToDisplay = _viewModel!.results.toList(); + }); + } + + void _onTapOption(MentionAutocompleteResult option) { + // Probably the same intent that brought up the option that was tapped. + // If not, it still shouldn't be off by more than the time it takes + // to compute the autocomplete results, which we do asynchronously. + final intent = widget.controller.autocompleteIntent(); + if (intent == null) { + return; // Shrug. + } + + final store = PerAccountStoreWidget.of(context); + final String replacementString; + switch (option) { + case UserMentionAutocompleteResult(:var userId): + // TODO(i18n) language-appropriate space character; check active keyboard? + // (maybe handle centrally in `widget.controller`) + replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} '; + case WildcardMentionAutocompleteResult(): + replacementString = '[unimplemented]'; // TODO + case UserGroupMentionAutocompleteResult(): + replacementString = '[unimplemented]'; // TODO + } + + widget.controller.value = intent.textEditingValue.replaced( + TextRange( + start: intent.syntaxStart, + end: intent.textEditingValue.selection.end), + replacementString, + ); + } + + Widget _buildItem(BuildContext _, int index) { + final option = _resultsToDisplay[index]; + String label; + switch (option) { + case UserMentionAutocompleteResult(:var userId): + // TODO avatar + label = PerAccountStoreWidget.of(context).users[userId]!.fullName; + case WildcardMentionAutocompleteResult(): + label = '[unimplemented]'; // TODO + case UserGroupMentionAutocompleteResult(): + label = '[unimplemented]'; // TODO + } + return InkWell( + onTap: () { + _onTapOption(option); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(label))); + } + @override Widget build(BuildContext context) { - return widget.fieldViewBuilder(context); + return RawAutocomplete( + textEditingController: widget.controller, + focusNode: widget.focusNode, + optionsBuilder: (_) => _resultsToDisplay, + optionsViewOpenDirection: OptionsViewOpenDirection.up, + // RawAutocomplete passes these when it calls optionsViewBuilder: + // AutocompleteOnSelected onSelected, + // Iterable options, + // + // We ignore them: + // - `onSelected` would cause some behavior we don't want, + // such as moving the cursor to the end of the compose-input text. + // - `options` would be needed if we were delegating to RawAutocomplete + // the work of creating the list of options. We're not; the + // `optionsBuilder` we pass is just a function that returns + // _resultsToDisplay, which is computed with lots of help from + // MentionAutocompleteView. + optionsViewBuilder: (context, _, __) { + return Align( + alignment: Alignment.bottomLeft, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), // TODO not hard-coded + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: _resultsToDisplay.length, + itemBuilder: _buildItem)))); + }, + // RawAutocomplete passes these when it calls fieldViewBuilder: + // TextEditingController textEditingController, + // FocusNode focusNode, + // VoidCallback onFieldSubmitted, + // + // We ignore them. For the first two, we've opted out of having + // RawAutocomplete create them for us; we create and manage them ourselves. + // The third isn't helpful; it lets us opt into behavior we don't actually + // want (see discussion: + // ) + fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + ); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart new file mode 100644 index 0000000000..311a56ba91 --- /dev/null +++ b/test/widgets/autocomplete_test.dart @@ -0,0 +1,107 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/widgets/message_list.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; + +/// Simulates loading a [MessageListPage] and tapping to focus the compose input. +/// +/// Also adds [users] to the [PerAccountStore], +/// so they can show up in autocomplete. +Future setupToComposeInput(WidgetTester tester, { + required List users, +}) async { + addTearDown(TestZulipBinding.instance.reset); + await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + store.addUsers([eg.selfUser, eg.otherUser]); + store.addUsers(users); + final connection = store.connection as FakeApiConnection; + + // prepare message list data + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + connection.prepare(json: GetMessagesResult( + anchor: message.id, + foundNewest: true, + foundOldest: true, + foundAnchor: true, + historyLimited: false, + messages: [message], + ).toJson()); + + await tester.pumpWidget( + MaterialApp( + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: MessageListPage( + narrow: DmNarrow( + allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId], + selfUserId: eg.selfUser.userId, + )))))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + // (hint text of compose input in a 1:1 DM) + final finder = find.widgetWithText(TextField, 'Message @${eg.otherUser.fullName}'); + check(finder.evaluate()).isNotEmpty(); + return finder; +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ComposeAutocomplete', () { + 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 composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + // Options are filtered correctly for query + // TODO(#226): Remove this extra edit when this bug is fixed. + 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(); + tester.widget(find.text('User Two')); + tester.widget(find.text('User Three')); + + // Finishing autocomplete updates compose box; causes options to disappear + await tester.tap(find.text('User Three')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(mention(user3, users: store.users)); + check(tester.widgetList(find.text('User One'))).isEmpty(); + check(tester.widgetList(find.text('User Two'))).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + + // Then a new autocomplete intent brings up options again + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user tw'); + await tester.enterText(composeInputFinder, 'hello @user two'); + await tester.pumpAndSettle(); // async computation; options appear + tester.widget(find.text('User Two')); + + // 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(find.text('User Two'))).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + }); + }); +}