645 lines
20 KiB
Dart
645 lines
20 KiB
Dart
// ignore_for_file: no-empty-block
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_portal/flutter_portal.dart';
|
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
|
|
|
export 'stream_command_autocomplete_options.dart';
|
|
export 'stream_mention_autocomplete_options.dart';
|
|
|
|
/// {@macro stream_chat_flutter.StreamMessageInputController}
|
|
typedef StreamMessageEditingController = StreamMessageInputController;
|
|
|
|
/// Positions the [AutocompleteTrigger] options around the [TextField] or
|
|
/// [TextFormField] that triggered the autocomplete.
|
|
enum OptionsAlignment {
|
|
/// The options are displayed below the field.
|
|
below,
|
|
|
|
/// The options are displayed above the field.
|
|
///
|
|
/// This is the default.
|
|
above;
|
|
|
|
Anchor _toAnchor() {
|
|
switch (this) {
|
|
case OptionsAlignment.below:
|
|
return const Aligned(
|
|
widthFactor: 1,
|
|
follower: Alignment.topCenter,
|
|
target: Alignment.bottomCenter,
|
|
);
|
|
case OptionsAlignment.above:
|
|
return const Aligned(
|
|
widthFactor: 1,
|
|
follower: Alignment.bottomCenter,
|
|
target: Alignment.topCenter,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The type of the Autocomplete callback which returns the widget that
|
|
/// contains the input [TextField] or [TextFormField].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [StreamAutocomplete.fieldViewBuilder], which is of this type.
|
|
typedef StreamAutocompleteFieldViewBuilder = Widget Function(
|
|
BuildContext context,
|
|
StreamMessageEditingController messageEditingController,
|
|
FocusNode focusNode,
|
|
);
|
|
|
|
/// The type of the [StreamAutocompleteTrigger] callback which returns a
|
|
/// [Widget] that displays the specified [options].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [StreamAutocompleteTrigger.optionsViewBuilder], which is of this type.
|
|
typedef StreamAutocompleteOptionsViewBuilder = Widget Function(
|
|
BuildContext context,
|
|
StreamAutocompleteQuery autocompleteQuery,
|
|
StreamMessageEditingController messageEditingController,
|
|
);
|
|
|
|
/// The query to determine the autocomplete options.
|
|
class StreamAutocompleteQuery {
|
|
/// Creates a [StreamAutocompleteQuery] with the specified [query] and
|
|
/// [selection].
|
|
const StreamAutocompleteQuery({
|
|
required this.query,
|
|
required this.selection,
|
|
});
|
|
|
|
/// The query string.
|
|
final String query;
|
|
|
|
/// The selection in the text field.
|
|
final TextSelection selection;
|
|
}
|
|
|
|
class _StreamAutocompleteInvokedTriggerWithQuery {
|
|
const _StreamAutocompleteInvokedTriggerWithQuery(this.trigger, this.query);
|
|
|
|
final StreamAutocompleteTrigger trigger;
|
|
final StreamAutocompleteQuery query;
|
|
}
|
|
|
|
/// The class responsible for triggering autocomplete suggestions and
|
|
/// displaying the options.
|
|
class StreamAutocompleteTrigger {
|
|
/// Creates a [StreamAutocompleteTrigger] which can be used to trigger
|
|
/// autocomplete suggestions.
|
|
const StreamAutocompleteTrigger({
|
|
required this.trigger,
|
|
required this.optionsViewBuilder,
|
|
this.triggerOnlyAfterSpace = false,
|
|
this.triggerOnlyAtStart = false,
|
|
this.minimumRequiredCharacters = 0,
|
|
});
|
|
|
|
/// The trigger character.
|
|
///
|
|
/// eg. '@', '#', ':'
|
|
final String trigger;
|
|
|
|
/// Whether the [trigger] should only be recognised at the start of the input.
|
|
final bool triggerOnlyAtStart;
|
|
|
|
/// Whether the [trigger] should only be recognised after a space.
|
|
final bool triggerOnlyAfterSpace;
|
|
|
|
/// The minimum required characters for the [trigger] to start recognising
|
|
/// a autocomplete options.
|
|
final int minimumRequiredCharacters;
|
|
|
|
/// Builds the widget responsible for querying and displaying the
|
|
/// autocomplete options.
|
|
///
|
|
/// See also:
|
|
/// * [StreamAutocompleteOptions], which helps in displaying the options.
|
|
final StreamAutocompleteOptionsViewBuilder optionsViewBuilder;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is StreamAutocompleteTrigger &&
|
|
runtimeType == other.runtimeType &&
|
|
trigger == other.trigger &&
|
|
triggerOnlyAtStart == other.triggerOnlyAtStart &&
|
|
triggerOnlyAfterSpace == other.triggerOnlyAfterSpace &&
|
|
minimumRequiredCharacters == other.minimumRequiredCharacters;
|
|
|
|
@override
|
|
int get hashCode =>
|
|
trigger.hashCode ^
|
|
triggerOnlyAtStart.hashCode ^
|
|
triggerOnlyAfterSpace.hashCode ^
|
|
minimumRequiredCharacters.hashCode;
|
|
|
|
/// Checks if the user is invoking the recognising [trigger] and returns
|
|
/// the autocomplete query if so.
|
|
StreamAutocompleteQuery? invokingTrigger(
|
|
Message message,
|
|
TextEditingValue textEditingValue,
|
|
) {
|
|
final text = textEditingValue.text;
|
|
final cursorPosition = textEditingValue.selection.baseOffset;
|
|
|
|
// Find the first [trigger] location before the input cursor.
|
|
final firstTriggerIndexBeforeCursor =
|
|
text.substring(0, cursorPosition).lastIndexOf(trigger);
|
|
|
|
// If the [trigger] is not found before the cursor, then it's not a trigger.
|
|
if (firstTriggerIndexBeforeCursor == -1) return null;
|
|
|
|
// If the [trigger] is found before the cursor, but the [trigger] is only
|
|
// recognised at the start of the input, then it's not a trigger.
|
|
if (triggerOnlyAtStart && firstTriggerIndexBeforeCursor != 0) {
|
|
return null;
|
|
}
|
|
|
|
// Only show typing suggestions after a space, or at the start of the input
|
|
// valid examples: "@user", "Hello @user"
|
|
// invalid examples: "Hello@user"
|
|
final textBeforeTrigger = text.substring(0, firstTriggerIndexBeforeCursor);
|
|
if (triggerOnlyAfterSpace &&
|
|
textBeforeTrigger.isNotEmpty &&
|
|
!textBeforeTrigger.endsWith(' ')) {
|
|
return null;
|
|
}
|
|
|
|
// The suggestion range. Protect against invalid ranges.
|
|
final suggestionStart = firstTriggerIndexBeforeCursor + trigger.length;
|
|
final suggestionEnd = cursorPosition;
|
|
if (suggestionStart > suggestionEnd) return null;
|
|
|
|
// Fetch the suggestion text. The suggestions can't have spaces.
|
|
// valid example: "@luke_skywa..."
|
|
// invalid example: "@luke skywa..."
|
|
final suggestionText = text.substring(suggestionStart, suggestionEnd);
|
|
if (suggestionText.contains(' ')) return null;
|
|
|
|
// A minimum number of characters can be provided to only show
|
|
// suggestions after the customer has input enough characters.
|
|
if (suggestionText.length < minimumRequiredCharacters) return null;
|
|
|
|
return StreamAutocompleteQuery(
|
|
query: suggestionText,
|
|
selection: TextSelection(
|
|
baseOffset: suggestionStart,
|
|
extentOffset: suggestionEnd,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A widget that provides a text field with autocomplete functionality.
|
|
class StreamAutocomplete extends StatefulWidget {
|
|
/// Create an instance of StreamAutocomplete.
|
|
///
|
|
/// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must
|
|
/// not be null.
|
|
const StreamAutocomplete({
|
|
super.key,
|
|
this.focusNode,
|
|
this.messageEditingController,
|
|
required this.autocompleteTriggers,
|
|
this.fieldViewBuilder = _defaultFieldViewBuilder,
|
|
this.optionsAlignment = OptionsAlignment.above,
|
|
this.debounceDuration = const Duration(milliseconds: 300),
|
|
}) : assert((focusNode == null) == (messageEditingController == null), '');
|
|
|
|
/// The triggers that trigger autocomplete.
|
|
final Iterable<StreamAutocompleteTrigger> autocompleteTriggers;
|
|
|
|
/// Builds the field whose input is used to get the options.
|
|
///
|
|
/// Pass the provided [StreamMessageEditingController] to the field built
|
|
/// here so that StreamAutocomplete can listen for changes.
|
|
final StreamAutocompleteFieldViewBuilder fieldViewBuilder;
|
|
|
|
/// The [FocusNode] that is used for the text field.
|
|
///
|
|
/// The main purpose of this parameter is to allow the use of a separate text
|
|
/// field located in another part of the widget tree instead of the text
|
|
/// field built by [fieldViewBuilder]. For example, it may be desirable to
|
|
/// place the text field in the AppBar and the options below in the main body.
|
|
///
|
|
/// When following this pattern, [fieldViewBuilder] can return
|
|
/// `SizedBox.shrink()` so that nothing is drawn where the text field would
|
|
/// normally be. A separate text field can be created elsewhere, and a
|
|
/// FocusNode and StreamMessageEditingController can be passed both to that
|
|
/// text field and to StreamAutocomplete.
|
|
///
|
|
/// If this parameter is not null, then [messageEditingController] must also
|
|
/// be not null.
|
|
final FocusNode? focusNode;
|
|
|
|
/// The [StreamMessageEditingController] that is used for the text field.
|
|
///
|
|
/// If this parameter is not null, then [focusNode] must also be not null.
|
|
final StreamMessageEditingController? messageEditingController;
|
|
|
|
/// The alignment of the options.
|
|
///
|
|
/// The default value is [OptionsAlignment.above].
|
|
final OptionsAlignment optionsAlignment;
|
|
|
|
/// The duration of the debounce period for the
|
|
/// [StreamMessageEditingController].
|
|
///
|
|
/// The default value is [300ms].
|
|
final Duration debounceDuration;
|
|
|
|
static Widget _defaultFieldViewBuilder(
|
|
BuildContext context,
|
|
StreamMessageEditingController messageEditingController,
|
|
FocusNode focusNode,
|
|
) {
|
|
return _StreamAutocompleteField(
|
|
focusNode: focusNode,
|
|
messageEditingController: messageEditingController,
|
|
);
|
|
}
|
|
|
|
/// Returns the nearest [StreamAutocomplete] ancestor of the given context.
|
|
static _StreamAutocompleteState of(BuildContext context) {
|
|
final state = context.findAncestorStateOfType<_StreamAutocompleteState>();
|
|
assert(state != null, 'StreamAutocomplete not found in the widget tree');
|
|
return state!;
|
|
}
|
|
|
|
@override
|
|
_StreamAutocompleteState createState() => _StreamAutocompleteState();
|
|
}
|
|
|
|
class _StreamAutocompleteState extends State<StreamAutocomplete> {
|
|
late StreamMessageEditingController _messageEditingController;
|
|
late FocusNode _focusNode;
|
|
|
|
StreamAutocompleteQuery? _currentQuery;
|
|
StreamAutocompleteTrigger? _currentTrigger;
|
|
|
|
bool _hideOptions = false;
|
|
String _lastFieldText = '';
|
|
|
|
// True if the state indicates that the options should be visible.
|
|
bool get _shouldShowOptions {
|
|
return !_hideOptions &&
|
|
_focusNode.hasFocus &&
|
|
_currentQuery != null &&
|
|
_currentTrigger != null;
|
|
}
|
|
|
|
/// Accepts and replaces the current query with the given [option] and closes
|
|
/// the suggested options.
|
|
///
|
|
/// Optionally, pass [keepTrigger] false to remove the trigger from the text.
|
|
void acceptAutocompleteOption(
|
|
String option, {
|
|
bool keepTrigger = true,
|
|
}) {
|
|
if (option.isEmpty) return;
|
|
|
|
final query = _currentQuery;
|
|
final trigger = _currentTrigger;
|
|
if (query == null || trigger == null) return;
|
|
|
|
final querySelection = query.selection;
|
|
final text = _messageEditingController.text;
|
|
|
|
var start = querySelection.baseOffset;
|
|
if (!keepTrigger) start -= 1;
|
|
|
|
final end = querySelection.extentOffset;
|
|
|
|
final alreadyContainsSpace = text.substring(end).startsWith(' ');
|
|
// Having extra space helps dismissing the auto-completion view.
|
|
// ignore: parameter_assignments
|
|
if (!alreadyContainsSpace) option += ' ';
|
|
|
|
var selectionOffset = start + option.length;
|
|
// In case the extra space is already there, we need to move the cursor
|
|
// after the space.
|
|
if (alreadyContainsSpace) selectionOffset += 1;
|
|
|
|
final newText = text.replaceRange(start, end, option);
|
|
final newSelection = TextSelection.collapsed(offset: selectionOffset);
|
|
|
|
_messageEditingController.textEditingValue = TextEditingValue(
|
|
text: newText,
|
|
selection: newSelection,
|
|
);
|
|
|
|
return closeSuggestions();
|
|
}
|
|
|
|
/// Closes the suggestions and resets the current query.
|
|
void closeSuggestions() {
|
|
final prev = _currentQuery;
|
|
if (prev == null) return;
|
|
|
|
_currentQuery = null;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
/// Starts showing the suggestions for the given [query].
|
|
void showSuggestions(
|
|
StreamAutocompleteQuery query,
|
|
StreamAutocompleteTrigger trigger,
|
|
) {
|
|
final prevQuery = _currentQuery;
|
|
final prevTrigger = _currentTrigger;
|
|
if (prevQuery == query && prevTrigger == trigger) return;
|
|
|
|
_currentQuery = query;
|
|
_currentTrigger = trigger;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
// Checks if there is any invoked autocomplete trigger and returns the first
|
|
// one along with the query that matches the current input.
|
|
_StreamAutocompleteInvokedTriggerWithQuery? _getInvokedTriggerWithQuery(
|
|
Message messageValue,
|
|
TextEditingValue textEditingValue,
|
|
) {
|
|
final autocompleteTriggers = widget.autocompleteTriggers.toSet();
|
|
for (final trigger in autocompleteTriggers) {
|
|
final query = trigger.invokingTrigger(messageValue, textEditingValue);
|
|
if (query != null) {
|
|
return _StreamAutocompleteInvokedTriggerWithQuery(trigger, query);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Called when _textEditingController changes.
|
|
late final _onChangedField = debounce(
|
|
() {
|
|
final messageValue = _messageEditingController.message;
|
|
final textEditingValue = _messageEditingController.textEditingValue;
|
|
|
|
// If the content has not changed, then there is nothing to do.
|
|
if (textEditingValue.text == _lastFieldText) return;
|
|
|
|
// Make sure the options are no longer hidden if the content of the
|
|
// field changes.
|
|
_hideOptions = false;
|
|
_lastFieldText = textEditingValue.text;
|
|
|
|
// If the text field is empty, then there is no need to do anything.
|
|
if (textEditingValue.text.isEmpty) return closeSuggestions();
|
|
|
|
// If the text field is not empty, then we need to check if the
|
|
// text field contains a trigger.
|
|
final _triggerWithQuery = _getInvokedTriggerWithQuery(
|
|
messageValue,
|
|
textEditingValue,
|
|
);
|
|
|
|
// If the text field does not contain a trigger, then there is no need
|
|
// to do anything.
|
|
if (_triggerWithQuery == null) return closeSuggestions();
|
|
|
|
// If the text field contains a trigger, then we need to open the
|
|
// portal.
|
|
final trigger = _triggerWithQuery.trigger;
|
|
final query = _triggerWithQuery.query;
|
|
return showSuggestions(query, trigger);
|
|
},
|
|
widget.debounceDuration,
|
|
);
|
|
|
|
// Called when the field's FocusNode changes.
|
|
void _onChangedFocus() {
|
|
// Options should no longer be hidden when the field is re-focused.
|
|
_hideOptions = !_focusNode.hasFocus;
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
// Handle a potential change in textEditingController by properly disposing of
|
|
// the old one and setting up the new one, if needed.
|
|
void _updateTextEditingController(
|
|
StreamMessageEditingController? old,
|
|
StreamMessageEditingController? current,
|
|
) {
|
|
if ((old == null && current == null) || old == current) {
|
|
return;
|
|
}
|
|
if (old == null) {
|
|
_messageEditingController
|
|
..removeListener(_onChangedField)
|
|
..dispose();
|
|
_messageEditingController = current!;
|
|
} else if (current == null) {
|
|
_messageEditingController.removeListener(_onChangedField);
|
|
_messageEditingController = StreamMessageEditingController();
|
|
} else {
|
|
_messageEditingController.removeListener(_onChangedField);
|
|
_messageEditingController = current;
|
|
}
|
|
_messageEditingController.addListener(_onChangedField);
|
|
}
|
|
|
|
// Handle a potential change in focusNode by properly disposing of the old one
|
|
// and setting up the new one, if needed.
|
|
void _updateFocusNode(FocusNode? old, FocusNode? current) {
|
|
if ((old == null && current == null) || old == current) {
|
|
return;
|
|
}
|
|
if (old == null) {
|
|
_focusNode
|
|
..removeListener(_onChangedFocus)
|
|
..dispose();
|
|
_focusNode = current!;
|
|
} else if (current == null) {
|
|
_focusNode.removeListener(_onChangedFocus);
|
|
_focusNode = FocusNode();
|
|
} else {
|
|
_focusNode.removeListener(_onChangedFocus);
|
|
_focusNode = current;
|
|
}
|
|
_focusNode.addListener(_onChangedFocus);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_messageEditingController =
|
|
widget.messageEditingController ?? StreamMessageEditingController();
|
|
_messageEditingController.addListener(_onChangedField);
|
|
_focusNode = widget.focusNode ?? FocusNode();
|
|
_focusNode.addListener(_onChangedFocus);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(StreamAutocomplete oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_updateTextEditingController(
|
|
oldWidget.messageEditingController,
|
|
widget.messageEditingController,
|
|
);
|
|
_updateFocusNode(oldWidget.focusNode, widget.focusNode);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_messageEditingController.removeListener(_onChangedField);
|
|
if (widget.messageEditingController == null) {
|
|
_messageEditingController.dispose();
|
|
}
|
|
_focusNode.removeListener(_onChangedFocus);
|
|
if (widget.focusNode == null) {
|
|
_focusNode.dispose();
|
|
}
|
|
_onChangedField.cancel();
|
|
closeSuggestions();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Adding additional builder so that [.of] works.
|
|
return Builder(
|
|
builder: (context) {
|
|
final anchor = widget.optionsAlignment._toAnchor();
|
|
final shouldShowOptions = _shouldShowOptions;
|
|
final optionViewBuilder = shouldShowOptions
|
|
? TextFieldTapRegion(
|
|
child: _currentTrigger!.optionsViewBuilder(
|
|
context,
|
|
_currentQuery!,
|
|
_messageEditingController,
|
|
),
|
|
)
|
|
: null;
|
|
|
|
return PortalTarget(
|
|
anchor: anchor,
|
|
visible: shouldShowOptions,
|
|
portalFollower: optionViewBuilder,
|
|
child: widget.fieldViewBuilder(
|
|
context,
|
|
_messageEditingController,
|
|
_focusNode,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// The default Material-style Autocomplete text field.
|
|
class _StreamAutocompleteField extends StatelessWidget {
|
|
const _StreamAutocompleteField({
|
|
required this.focusNode,
|
|
required this.messageEditingController,
|
|
});
|
|
|
|
final FocusNode focusNode;
|
|
|
|
final StreamMessageEditingController messageEditingController;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return StreamMessageTextField(
|
|
controller: messageEditingController,
|
|
focusNode: focusNode,
|
|
);
|
|
}
|
|
}
|
|
|
|
const _kDefaultStreamAutocompleteOptionsShape = RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
);
|
|
|
|
/// A helper widget used to show the options of a [StreamAutocomplete].
|
|
class StreamAutocompleteOptions<T extends Object> extends StatelessWidget {
|
|
/// Creates a [StreamAutocompleteOptions] widget.
|
|
const StreamAutocompleteOptions({
|
|
super.key,
|
|
this.color,
|
|
this.elevation = 2,
|
|
this.margin = const EdgeInsets.all(8),
|
|
this.clipBehavior = Clip.hardEdge,
|
|
required this.options,
|
|
this.maxHeight,
|
|
required this.optionBuilder,
|
|
this.headerBuilder,
|
|
this.shape = _kDefaultStreamAutocompleteOptionsShape,
|
|
});
|
|
|
|
/// The background color of the options card.
|
|
///
|
|
/// Defaults to [StreamColorTheme.barsBg].
|
|
final Color? color;
|
|
|
|
/// The elevation of the options card.
|
|
///
|
|
/// The default value is 2.
|
|
final double elevation;
|
|
|
|
/// The margin of the options card.
|
|
///
|
|
/// The default value is [EdgeInsets.all(8)].
|
|
final EdgeInsetsGeometry margin;
|
|
|
|
/// The clip behavior of the options card.
|
|
///
|
|
/// The default value is [Clip.hardEdge].
|
|
final Clip clipBehavior;
|
|
|
|
/// The shape of the options card.
|
|
final ShapeBorder shape;
|
|
|
|
/// The options to display.
|
|
final Iterable<T> options;
|
|
|
|
/// The maximum height of the options card.
|
|
///
|
|
/// Defaults to half the height of the screen.
|
|
final double? maxHeight;
|
|
|
|
/// The builder for the options.
|
|
final Widget Function(BuildContext context, T option) optionBuilder;
|
|
|
|
/// The builder for the header of the options.
|
|
final WidgetBuilder? headerBuilder;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final height = MediaQuery.of(context).size.height;
|
|
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
|
return Card(
|
|
margin: margin,
|
|
elevation: elevation,
|
|
color: color ?? colorTheme.barsBg,
|
|
shape: shape,
|
|
clipBehavior: clipBehavior,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (headerBuilder != null) ...[
|
|
headerBuilder!(context),
|
|
const Divider(height: 0),
|
|
],
|
|
LimitedBox(
|
|
maxHeight: maxHeight ?? height * 0.5,
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
padding: EdgeInsets.zero,
|
|
itemCount: options.length,
|
|
itemBuilder: (context, index) {
|
|
final option = options.elementAt(index);
|
|
return optionBuilder(context, option);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|