Skip to content
Merged
4 changes: 2 additions & 2 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ class MentionAutocompleteView extends ChangeNotifier {
}
}
}
return results;
return results; // TODO sort for most relevant first
}
}

Expand Down Expand Up @@ -331,7 +331,7 @@ class AutocompleteDataCache {
}
}

abstract class MentionAutocompleteResult {}
sealed class MentionAutocompleteResult {}

class UserMentionAutocompleteResult extends MentionAutocompleteResult {
UserMentionAutocompleteResult({required this.userId});
Expand Down
2 changes: 1 addition & 1 deletion lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, User>? 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}' : ''}**';
}
Expand Down
174 changes: 174 additions & 0 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';

import 'store.dart';
import '../model/autocomplete.dart';
import '../model/compose.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<ComposeAutocomplete> createState() => _ComposeAutocompleteState();
}

class _ComposeAutocompleteState extends State<ComposeAutocomplete> {
MentionAutocompleteView? _viewModel; // TODO different autocomplete view types

void _composeContentChanged() {
final newAutocompleteIntent = widget.controller.autocompleteIntent();
if (newAutocompleteIntent != null) {
final store = PerAccountStoreWidget.of(context);
_viewModel ??= MentionAutocompleteView.init(store: store, narrow: widget.narrow)
..addListener(_viewModelChanged);
_viewModel!.query = newAutocompleteIntent.query;
} else {
if (_viewModel != null) {
_viewModel!.dispose(); // removes our listener
_viewModel = null;
_resultsToDisplay = [];
}
}
}

@override
void initState() {
super.initState();
widget.controller.addListener(_composeContentChanged);
}

@override
void didUpdateWidget(covariant ComposeAutocomplete oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_composeContentChanged);
widget.controller.addListener(_composeContentChanged);
}
}

@override
void dispose() {
widget.controller.removeListener(_composeContentChanged);
_viewModel?.dispose(); // removes our listener
super.dispose();
}

List<MentionAutocompleteResult> _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 RawAutocomplete<MentionAutocompleteResult>(
textEditingController: widget.controller,
focusNode: widget.focusNode,
optionsBuilder: (_) => _resultsToDisplay,
optionsViewOpenDirection: OptionsViewOpenDirection.up,
// RawAutocomplete passes these when it calls optionsViewBuilder:
// AutocompleteOnSelected<T> onSelected,
// Iterable<T> 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:
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/autocomplete.20UI/near/1599994>)
fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context),
);
}
}
68 changes: 16 additions & 52 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -263,7 +263,7 @@ class ComposeContentController extends ComposeController<ContentValidationError>
}
}

class _ContentInput extends StatefulWidget {
class _ContentInput extends StatelessWidget {
const _ContentInput({
required this.narrow,
required this.controller,
Expand All @@ -276,49 +276,6 @@ class _ContentInput extends StatefulWidget {
final FocusNode focusNode;
final String hintText;

@override
State<_ContentInput> createState() => _ContentInputState();
}

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);
super.dispose();
}

@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
Expand All @@ -332,13 +289,20 @@ class _ContentInputState extends State<_ContentInput> {
// TODO constrain this adaptively (i.e. not hard-coded 200)
maxHeight: 200,
),
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration.collapsed(hintText: widget.hintText),
maxLines: null,
)));
child: ComposeAutocomplete(
narrow: narrow,
controller: controller,
focusNode: focusNode,
fieldViewBuilder: (context) {
return TextField(
controller: controller,
focusNode: focusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration.collapsed(hintText: hintText),
maxLines: null,
);
}),
));
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/model/compose_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading