Skip to content

Commit 7c24173

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 7d9cc85 commit 7c24173

File tree

1 file changed

+91
-71
lines changed

1 file changed

+91
-71
lines changed

lib/widgets/autocomplete.dart

Lines changed: 91 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,108 @@
11
import 'package:flutter/material.dart';
22

3+
import '../api/model/model.dart';
34
import 'content.dart';
45
import 'store.dart';
56
import '../model/autocomplete.dart';
67
import '../model/compose.dart';
78
import '../model/narrow.dart';
89
import 'compose_box.dart';
910

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

19-
/// The message list's narrow.
20-
final Narrow narrow;
21-
22-
final ComposeContentController controller;
85+
final TextEditingController controller;
2386
final FocusNode focusNode;
2487
final WidgetBuilder fieldViewBuilder;
88+
final Widget Function(BuildContext, int, ResultT) itemBuilder;
89+
final AutocompleteView<QueryT, ResultT, CandidateT> Function(BuildContext) viewModelBuilder;
90+
final AutocompleteIntent<QueryT>? Function() getAutocompleteIntent;
2591

2692
@override
27-
State<ComposeAutocomplete> createState() => _ComposeAutocompleteState();
93+
State<AutocompleteField<QueryT, ResultT, CandidateT>> createState() => _AutocompleteFieldState<QueryT, ResultT, CandidateT>();
2894
}
2995

30-
class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccountStoreAwareStateMixin<ComposeAutocomplete> {
31-
MentionAutocompleteView? _viewModel; // TODO different autocomplete view types
96+
class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends AutocompleteResult, CandidateT> extends State<AutocompleteField<QueryT, ResultT, CandidateT>> with PerAccountStoreAwareStateMixin<AutocompleteField<QueryT, ResultT, CandidateT>> {
97+
AutocompleteView<QueryT, ResultT, CandidateT>? _viewModel;
3298

3399
void _initViewModel() {
34-
final store = PerAccountStoreWidget.of(context);
35-
_viewModel = MentionAutocompleteView.init(store: store, narrow: widget.narrow)
100+
_viewModel = widget.viewModelBuilder(context)
36101
..addListener(_viewModelChanged);
37102
}
38103

39-
void _composeContentChanged() {
40-
final newAutocompleteIntent = widget.controller.autocompleteIntent();
104+
void _onChanged() {
105+
final AutocompleteIntent<QueryT>? newAutocompleteIntent = widget.getAutocompleteIntent();
41106
if (newAutocompleteIntent != null) {
42107
if (_viewModel == null) {
43108
_initViewModel();
@@ -55,7 +120,7 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
55120
@override
56121
void initState() {
57122
super.initState();
58-
widget.controller.addListener(_composeContentChanged);
123+
widget.controller.addListener(_onChanged);
59124
}
60125

61126
@override
@@ -69,88 +134,43 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
69134
}
70135

71136
@override
72-
void didUpdateWidget(covariant ComposeAutocomplete oldWidget) {
137+
void didUpdateWidget(covariant AutocompleteField<QueryT, ResultT, CandidateT> oldWidget) {
73138
super.didUpdateWidget(oldWidget);
74139
if (widget.controller != oldWidget.controller) {
75-
oldWidget.controller.removeListener(_composeContentChanged);
76-
widget.controller.addListener(_composeContentChanged);
140+
oldWidget.controller.removeListener(_onChanged);
141+
widget.controller.addListener(_onChanged);
77142
}
78143
}
79144

80145
@override
81146
void dispose() {
82-
widget.controller.removeListener(_composeContentChanged);
147+
widget.controller.removeListener(_onChanged);
83148
_viewModel?.dispose(); // removes our listener
84149
super.dispose();
85150
}
86151

87-
List<MentionAutocompleteResult> _resultsToDisplay = [];
152+
List<ResultT> _resultsToDisplay = [];
88153

89154
void _viewModelChanged() {
90155
setState(() {
91156
_resultsToDisplay = _viewModel!.results.toList();
92157
});
93158
}
94159

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-
])));
160+
Widget _buildItem(BuildContext context, int index) {
161+
return widget.itemBuilder(context, index, _resultsToDisplay[index]);
142162
}
143163

144164
@override
145165
Widget build(BuildContext context) {
146-
return RawAutocomplete<MentionAutocompleteResult>(
166+
return RawAutocomplete<ResultT>(
147167
textEditingController: widget.controller,
148168
focusNode: widget.focusNode,
149169
optionsBuilder: (_) => _resultsToDisplay,
150170
optionsViewOpenDirection: OptionsViewOpenDirection.up,
151171
// RawAutocomplete passes these when it calls optionsViewBuilder:
152-
// AutocompleteOnSelected<T> onSelected,
153-
// Iterable<T> options,
172+
// AutocompleteOnSelected<CandidateT> onSelected,
173+
// Iterable<CandidateT> options,
154174
//
155175
// We ignore them:
156176
// - `onSelected` would cause some behavior we don't want,
@@ -159,7 +179,7 @@ class _ComposeAutocompleteState extends State<ComposeAutocomplete> with PerAccou
159179
// the work of creating the list of options. We're not; the
160180
// `optionsBuilder` we pass is just a function that returns
161181
// _resultsToDisplay, which is computed with lots of help from
162-
// MentionAutocompleteView.
182+
// AutocompleteView.
163183
optionsViewBuilder: (context, _, __) {
164184
return Align(
165185
alignment: Alignment.bottomLeft,

0 commit comments

Comments
 (0)