Skip to content

Commit 234b65d

Browse files
chrisbobbegnprice
authored andcommitted
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
1 parent a8be6bd commit 234b65d

File tree

2 files changed

+214
-5
lines changed

2 files changed

+214
-5
lines changed

lib/widgets/autocomplete.dart

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22

33
import 'store.dart';
44
import '../model/autocomplete.dart';
5+
import '../model/compose.dart';
56
import '../model/narrow.dart';
67
import 'compose_box.dart';
78

@@ -32,13 +33,14 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> {
3233
final newAutocompleteIntent = widget.controller.autocompleteIntent();
3334
if (newAutocompleteIntent != null) {
3435
final store = PerAccountStoreWidget.of(context);
35-
_viewModel ??= MentionAutocompleteView.init(
36-
store: store, narrow: widget.narrow);
36+
_viewModel ??= MentionAutocompleteView.init(store: store, narrow: widget.narrow)
37+
..addListener(_viewModelChanged);
3738
_viewModel!.query = newAutocompleteIntent.query;
3839
} else {
3940
if (_viewModel != null) {
40-
_viewModel!.dispose();
41+
_viewModel!.dispose(); // removes our listener
4142
_viewModel = null;
43+
_resultsToDisplay = [];
4244
}
4345
}
4446
}
@@ -61,12 +63,112 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> {
6163
@override
6264
void dispose() {
6365
widget.controller.removeListener(_composeContentChanged);
64-
_viewModel?.dispose();
66+
_viewModel?.dispose(); // removes our listener
6567
super.dispose();
6668
}
6769

70+
List<MentionAutocompleteResult> _resultsToDisplay = [];
71+
72+
void _viewModelChanged() {
73+
setState(() {
74+
_resultsToDisplay = _viewModel!.results.toList();
75+
});
76+
}
77+
78+
void _onTapOption(MentionAutocompleteResult option) {
79+
// Probably the same intent that brought up the option that was tapped.
80+
// If not, it still shouldn't be off by more than the time it takes
81+
// to compute the autocomplete results, which we do asynchronously.
82+
final intent = widget.controller.autocompleteIntent();
83+
if (intent == null) {
84+
return; // Shrug.
85+
}
86+
87+
final store = PerAccountStoreWidget.of(context);
88+
final String replacementString;
89+
switch (option) {
90+
case UserMentionAutocompleteResult(:var userId):
91+
// TODO(i18n) language-appropriate space character; check active keyboard?
92+
// (maybe handle centrally in `widget.controller`)
93+
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
94+
case WildcardMentionAutocompleteResult():
95+
replacementString = '[unimplemented]'; // TODO
96+
case UserGroupMentionAutocompleteResult():
97+
replacementString = '[unimplemented]'; // TODO
98+
}
99+
100+
widget.controller.value = intent.textEditingValue.replaced(
101+
TextRange(
102+
start: intent.syntaxStart,
103+
end: intent.textEditingValue.selection.end),
104+
replacementString,
105+
);
106+
}
107+
108+
Widget _buildItem(BuildContext _, int index) {
109+
final option = _resultsToDisplay[index];
110+
String label;
111+
switch (option) {
112+
case UserMentionAutocompleteResult(:var userId):
113+
// TODO avatar
114+
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
115+
case WildcardMentionAutocompleteResult():
116+
label = '[unimplemented]'; // TODO
117+
case UserGroupMentionAutocompleteResult():
118+
label = '[unimplemented]'; // TODO
119+
}
120+
return InkWell(
121+
onTap: () {
122+
_onTapOption(option);
123+
},
124+
child: Padding(
125+
padding: const EdgeInsets.all(16.0),
126+
child: Text(label)));
127+
}
128+
68129
@override
69130
Widget build(BuildContext context) {
70-
return widget.fieldViewBuilder(context);
131+
return RawAutocomplete<MentionAutocompleteResult>(
132+
textEditingController: widget.controller,
133+
focusNode: widget.focusNode,
134+
optionsBuilder: (_) => _resultsToDisplay,
135+
optionsViewOpenDirection: OptionsViewOpenDirection.up,
136+
// RawAutocomplete passes these when it calls optionsViewBuilder:
137+
// AutocompleteOnSelected<T> onSelected,
138+
// Iterable<T> options,
139+
//
140+
// We ignore them:
141+
// - `onSelected` would cause some behavior we don't want,
142+
// such as moving the cursor to the end of the compose-input text.
143+
// - `options` would be needed if we were delegating to RawAutocomplete
144+
// the work of creating the list of options. We're not; the
145+
// `optionsBuilder` we pass is just a function that returns
146+
// _resultsToDisplay, which is computed with lots of help from
147+
// MentionAutocompleteView.
148+
optionsViewBuilder: (context, _, __) {
149+
return Align(
150+
alignment: Alignment.bottomLeft,
151+
child: Material(
152+
elevation: 4.0,
153+
child: ConstrainedBox(
154+
constraints: const BoxConstraints(maxHeight: 300), // TODO not hard-coded
155+
child: ListView.builder(
156+
padding: EdgeInsets.zero,
157+
shrinkWrap: true,
158+
itemCount: _resultsToDisplay.length,
159+
itemBuilder: _buildItem))));
160+
},
161+
// RawAutocomplete passes these when it calls fieldViewBuilder:
162+
// TextEditingController textEditingController,
163+
// FocusNode focusNode,
164+
// VoidCallback onFieldSubmitted,
165+
//
166+
// We ignore them. For the first two, we've opted out of having
167+
// RawAutocomplete create them for us; we create and manage them ourselves.
168+
// The third isn't helpful; it lets us opt into behavior we don't actually
169+
// want (see discussion:
170+
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/autocomplete.20UI/near/1599994>)
171+
fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context),
172+
);
71173
}
72174
}

test/widgets/autocomplete_test.dart

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/api/route/messages.dart';
6+
import 'package:zulip/model/compose.dart';
7+
import 'package:zulip/model/narrow.dart';
8+
import 'package:zulip/widgets/message_list.dart';
9+
import 'package:zulip/widgets/store.dart';
10+
11+
import '../api/fake_api.dart';
12+
import '../example_data.dart' as eg;
13+
import '../model/binding.dart';
14+
import '../model/test_store.dart';
15+
16+
/// Simulates loading a [MessageListPage] and tapping to focus the compose input.
17+
///
18+
/// Also adds [users] to the [PerAccountStore],
19+
/// so they can show up in autocomplete.
20+
Future<Finder> setupToComposeInput(WidgetTester tester, {
21+
required List<User> users,
22+
}) async {
23+
addTearDown(TestZulipBinding.instance.reset);
24+
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
25+
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
26+
store.addUsers([eg.selfUser, eg.otherUser]);
27+
store.addUsers(users);
28+
final connection = store.connection as FakeApiConnection;
29+
30+
// prepare message list data
31+
final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
32+
connection.prepare(json: GetMessagesResult(
33+
anchor: message.id,
34+
foundNewest: true,
35+
foundOldest: true,
36+
foundAnchor: true,
37+
historyLimited: false,
38+
messages: [message],
39+
).toJson());
40+
41+
await tester.pumpWidget(
42+
MaterialApp(
43+
home: GlobalStoreWidget(
44+
child: PerAccountStoreWidget(
45+
accountId: eg.selfAccount.id,
46+
child: MessageListPage(
47+
narrow: DmNarrow(
48+
allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId],
49+
selfUserId: eg.selfUser.userId,
50+
))))));
51+
52+
// global store, per-account store, and message list get loaded
53+
await tester.pumpAndSettle();
54+
55+
// (hint text of compose input in a 1:1 DM)
56+
final finder = find.widgetWithText(TextField, 'Message @${eg.otherUser.fullName}');
57+
check(finder.evaluate()).isNotEmpty();
58+
return finder;
59+
}
60+
61+
void main() {
62+
TestZulipBinding.ensureInitialized();
63+
64+
group('ComposeAutocomplete', () {
65+
testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async {
66+
final user1 = eg.user(userId: 1, fullName: 'User One');
67+
final user2 = eg.user(userId: 2, fullName: 'User Two');
68+
final user3 = eg.user(userId: 3, fullName: 'User Three');
69+
final composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]);
70+
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
71+
72+
// Options are filtered correctly for query
73+
// TODO(#226): Remove this extra edit when this bug is fixed.
74+
await tester.enterText(composeInputFinder, 'hello @user ');
75+
await tester.enterText(composeInputFinder, 'hello @user t');
76+
await tester.pumpAndSettle(); // async computation; options appear
77+
// "User Two" and "User Three" appear, but not "User One"
78+
check(tester.widgetList(find.text('User One'))).isEmpty();
79+
tester.widget(find.text('User Two'));
80+
tester.widget(find.text('User Three'));
81+
82+
// Finishing autocomplete updates compose box; causes options to disappear
83+
await tester.tap(find.text('User Three'));
84+
await tester.pump();
85+
check(tester.widget<TextField>(composeInputFinder).controller!.text)
86+
.contains(mention(user3, users: store.users));
87+
check(tester.widgetList(find.text('User One'))).isEmpty();
88+
check(tester.widgetList(find.text('User Two'))).isEmpty();
89+
check(tester.widgetList(find.text('User Three'))).isEmpty();
90+
91+
// Then a new autocomplete intent brings up options again
92+
// TODO(#226): Remove this extra edit when this bug is fixed.
93+
await tester.enterText(composeInputFinder, 'hello @user tw');
94+
await tester.enterText(composeInputFinder, 'hello @user two');
95+
await tester.pumpAndSettle(); // async computation; options appear
96+
tester.widget(find.text('User Two'));
97+
98+
// Removing autocomplete intent causes options to disappear
99+
// TODO(#226): Remove one of these edits when this bug is fixed.
100+
await tester.enterText(composeInputFinder, '');
101+
await tester.enterText(composeInputFinder, ' ');
102+
check(tester.widgetList(find.text('User One'))).isEmpty();
103+
check(tester.widgetList(find.text('User Two'))).isEmpty();
104+
check(tester.widgetList(find.text('User Three'))).isEmpty();
105+
});
106+
});
107+
}

0 commit comments

Comments
 (0)