Skip to content

Commit 84b8e50

Browse files
committed
autocomplete [nfc]: Factor out generic AutocompleteField
Most of the logic in `ComposeAutocomplete` is not specific to the content input it self rather it is general logic that applies to any autocomplete field.
1 parent d8c373a commit 84b8e50

File tree

1 file changed

+99
-84
lines changed

1 file changed

+99
-84
lines changed

lib/widgets/autocomplete.dart

+99-84
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,101 @@ import '../model/compose.dart';
77
import '../model/narrow.dart';
88
import 'compose_box.dart';
99

10-
class ComposeAutocomplete extends StatefulWidget {
11-
const ComposeAutocomplete({
10+
class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, MentionAutocompleteResult> {
11+
ComposeAutocomplete({
12+
super.key,
13+
required Narrow narrow,
14+
required ComposeContentController controller,
15+
required super.focusNode,
16+
required super.fieldViewBuilder
17+
}) : super(
18+
controller: controller,
19+
getAutocompleteIntent: () => controller.autocompleteIntent(),
20+
viewModelBuilder: (context) {
21+
final store = PerAccountStoreWidget.of(context);
22+
return MentionAutocompleteView.init(store: store, narrow: narrow);
23+
},
24+
itemBuilder: (context, index, option) {
25+
Widget avatar;
26+
String label;
27+
switch (option) {
28+
case UserMentionAutocompleteResult(:var userId):
29+
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
30+
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
31+
default:
32+
avatar = const SizedBox();
33+
label = '';
34+
}
35+
return InkWell(
36+
onTap: () {
37+
// Probably the same intent that brought up the option that was tapped.
38+
// If not, it still shouldn't be off by more than the time it takes
39+
// to compute the autocomplete results, which we do asynchronously.
40+
final intent = controller.autocompleteIntent();
41+
if (intent == null) {
42+
return; // Shrug.
43+
}
44+
45+
final store = PerAccountStoreWidget.of(context);
46+
final String replacementString;
47+
switch (option) {
48+
case UserMentionAutocompleteResult(:var userId):
49+
// TODO(i18n) language-appropriate space character; check active keyboard?
50+
// (maybe handle centrally in `controller`)
51+
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
52+
default:
53+
replacementString = '';
54+
}
55+
56+
controller.value = intent.textEditingValue.replaced(
57+
TextRange(
58+
start: intent.syntaxStart,
59+
end: intent.textEditingValue.selection.end),
60+
replacementString,
61+
);
62+
},
63+
child: Padding(
64+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
65+
child: Row(
66+
children: [
67+
avatar,
68+
const SizedBox(width: 8),
69+
Text(label)])));
70+
});
71+
}
72+
73+
class AutocompleteField<Q extends AutocompleteQuery, R extends AutocompleteResult> extends StatefulWidget {
74+
const AutocompleteField({
1275
super.key,
13-
required this.narrow,
1476
required this.controller,
1577
required this.focusNode,
1678
required this.fieldViewBuilder,
79+
required this.itemBuilder,
80+
required this.viewModelBuilder,
81+
required this.getAutocompleteIntent,
1782
});
1883

19-
/// The message list's narrow.
20-
final Narrow narrow;
21-
22-
final ComposeContentController controller;
84+
final TextEditingController controller;
2385
final FocusNode focusNode;
2486
final WidgetBuilder fieldViewBuilder;
87+
final Widget? Function(BuildContext, int, R) itemBuilder;
88+
final AutocompleteView<Q, R> Function(BuildContext) viewModelBuilder;
89+
final AutocompleteIntent<Q>? Function() getAutocompleteIntent;
2590

2691
@override
27-
State<ComposeAutocomplete> createState() => _ComposeAutocompleteState();
92+
State<AutocompleteField<Q, R>> createState() => _AutocompleteFieldState<Q, R>();
2893
}
2994

30-
class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccountStoreAwareStateMixin<ComposeAutocomplete> {
31-
MentionAutocompleteView? _viewModel; // TODO different autocomplete view types
95+
class _AutocompleteFieldState<Q extends AutocompleteQuery, R extends AutocompleteResult> extends State<AutocompleteField<Q, R>> with PerAccountStoreAwareStateMixin<AutocompleteField<Q, R>> {
96+
AutocompleteView<Q, R>? _viewModel;
3297

3398
void _initViewModel() {
34-
final store = PerAccountStoreWidget.of(context);
35-
_viewModel = MentionAutocompleteView.init(store: store, narrow: widget.narrow)
99+
_viewModel = widget.viewModelBuilder(context)
36100
..addListener(_viewModelChanged);
37101
}
38102

39-
void _composeContentChanged() {
40-
final newAutocompleteIntent = widget.controller.autocompleteIntent();
103+
void _onChanged() {
104+
final AutocompleteIntent<Q>? newAutocompleteIntent = widget.getAutocompleteIntent();
41105
if (newAutocompleteIntent != null) {
42106
if (_viewModel == null) {
43107
_initViewModel();
@@ -55,7 +119,7 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
55119
@override
56120
void initState() {
57121
super.initState();
58-
widget.controller.addListener(_composeContentChanged);
122+
widget.controller.addListener(_onChanged);
59123
}
60124

61125
@override
@@ -69,81 +133,32 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
69133
}
70134

71135
@override
72-
void didUpdateWidget(covariant ComposeAutocomplete oldWidget) {
136+
void didUpdateWidget(covariant AutocompleteField<Q, R> oldWidget) {
73137
super.didUpdateWidget(oldWidget);
74138
if (widget.controller != oldWidget.controller) {
75-
oldWidget.controller.removeListener(_composeContentChanged);
76-
widget.controller.addListener(_composeContentChanged);
139+
oldWidget.controller.removeListener(_onChanged);
140+
widget.controller.addListener(_onChanged);
77141
}
78142
}
79143

80144
@override
81145
void dispose() {
82-
widget.controller.removeListener(_composeContentChanged);
146+
widget.controller.removeListener(_onChanged);
83147
_viewModel?.dispose(); // removes our listener
84148
super.dispose();
85149
}
86150

87-
List<MentionAutocompleteResult> _resultsToDisplay = [];
151+
List<R> _resultsToDisplay = [];
88152

89153
void _viewModelChanged() {
90154
setState(() {
91155
_resultsToDisplay = _viewModel!.results.toList();
92156
});
93157
}
94158

95-
void _onTapOption(MentionAutocompleteResult option) {
96-
// Probably the same intent that brought up the option that was tapped.
97-
// If not, it still shouldn't be off by more than the time it takes
98-
// to compute the autocomplete results, which we do asynchronously.
99-
final intent = widget.controller.autocompleteIntent();
100-
if (intent == null) {
101-
return; // Shrug.
102-
}
103-
104-
final store = PerAccountStoreWidget.of(context);
105-
final String replacementString;
106-
switch (option) {
107-
case UserMentionAutocompleteResult(:var userId):
108-
// TODO(i18n) language-appropriate space character; check active keyboard?
109-
// (maybe handle centrally in `widget.controller`)
110-
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
111-
}
112-
113-
widget.controller.value = intent.textEditingValue.replaced(
114-
TextRange(
115-
start: intent.syntaxStart,
116-
end: intent.textEditingValue.selection.end),
117-
replacementString,
118-
);
119-
}
120-
121-
Widget _buildItem(BuildContext _, int index) {
122-
final option = _resultsToDisplay[index];
123-
Widget avatar;
124-
String label;
125-
switch (option) {
126-
case UserMentionAutocompleteResult(:var userId):
127-
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
128-
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
129-
}
130-
return InkWell(
131-
onTap: () {
132-
_onTapOption(option);
133-
},
134-
child: Padding(
135-
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
136-
child: Row(
137-
children: [
138-
avatar,
139-
const SizedBox(width: 8),
140-
Text(label),
141-
])));
142-
}
143-
144159
@override
145160
Widget build(BuildContext context) {
146-
return RawAutocomplete<MentionAutocompleteResult>(
161+
return RawAutocomplete<R>(
147162
textEditingController: widget.controller,
148163
focusNode: widget.focusNode,
149164
optionsBuilder: (_) => _resultsToDisplay,
@@ -159,20 +174,20 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
159174
// the work of creating the list of options. We're not; the
160175
// `optionsBuilder` we pass is just a function that returns
161176
// _resultsToDisplay, which is computed with lots of help from
162-
// MentionAutocompleteView.
163-
optionsViewBuilder: (context, _, __) {
164-
return Align(
165-
alignment: Alignment.bottomLeft,
166-
child: Material(
167-
elevation: 4.0,
168-
child: ConstrainedBox(
169-
constraints: const BoxConstraints(maxHeight: 300), // TODO not hard-coded
170-
child: ListView.builder(
171-
padding: EdgeInsets.zero,
172-
shrinkWrap: true,
173-
itemCount: _resultsToDisplay.length,
174-
itemBuilder: _buildItem))));
175-
},
177+
// AutocompleteView.
178+
optionsViewBuilder: (context, _, __) => Align(
179+
alignment: Alignment.bottomLeft,
180+
child: Material(
181+
elevation: 4.0,
182+
child: ConstrainedBox(
183+
constraints: const BoxConstraints(maxHeight: 300), // TODO not hard-coded
184+
child: ListView.builder(
185+
padding: EdgeInsets.zero,
186+
shrinkWrap: true,
187+
itemCount: _resultsToDisplay.length,
188+
itemBuilder: (context, index) {
189+
return widget.itemBuilder(context, index, _resultsToDisplay[index]);
190+
})))),
176191
// RawAutocomplete passes these when it calls fieldViewBuilder:
177192
// TextEditingController textEditingController,
178193
// FocusNode focusNode,

0 commit comments

Comments
 (0)