|
|
|
|
@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:emojis/emoji.dart';
|
|
|
|
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
|
|
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
|
import 'package:slugify/slugify.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:fluffychat/config/app_config.dart';
|
|
|
|
|
import 'package:fluffychat/config/setting_keys.dart';
|
|
|
|
|
import 'package:fluffychat/l10n/l10n.dart';
|
|
|
|
|
import 'package:fluffychat/utils/markdown_context_builder.dart';
|
|
|
|
|
@ -46,14 +46,12 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
super.key,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<Map<String, String?>> getSuggestions(String text) {
|
|
|
|
|
if (controller!.selection.baseOffset !=
|
|
|
|
|
controller!.selection.extentOffset ||
|
|
|
|
|
controller!.selection.baseOffset < 0) {
|
|
|
|
|
List<Map<String, String?>> getSuggestions(TextEditingValue text) {
|
|
|
|
|
if (text.selection.baseOffset != text.selection.extentOffset ||
|
|
|
|
|
text.selection.baseOffset < 0) {
|
|
|
|
|
return []; // no entries if there is selected text
|
|
|
|
|
}
|
|
|
|
|
final searchText =
|
|
|
|
|
controller!.text.substring(0, controller!.selection.baseOffset);
|
|
|
|
|
final searchText = text.text.substring(0, text.selection.baseOffset);
|
|
|
|
|
final ret = <Map<String, String?>>[];
|
|
|
|
|
const maxResults = 30;
|
|
|
|
|
|
|
|
|
|
@ -218,33 +216,28 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
Widget buildSuggestion(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
Map<String, String?> suggestion,
|
|
|
|
|
void Function(Map<String, String?>) onSelected,
|
|
|
|
|
Client? client,
|
|
|
|
|
) {
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
const size = 30.0;
|
|
|
|
|
const padding = EdgeInsets.all(4.0);
|
|
|
|
|
if (suggestion['type'] == 'command') {
|
|
|
|
|
final command = suggestion['name']!;
|
|
|
|
|
final hint = commandHint(L10n.of(context), command);
|
|
|
|
|
return Tooltip(
|
|
|
|
|
message: hint,
|
|
|
|
|
waitDuration: const Duration(days: 1), // don't show on hover
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: padding,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
commandExample(command),
|
|
|
|
|
style: const TextStyle(fontFamily: 'RobotoMono'),
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
hint,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: theme.textTheme.bodySmall,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
child: ListTile(
|
|
|
|
|
onTap: () => onSelected(suggestion),
|
|
|
|
|
title: Text(
|
|
|
|
|
commandExample(command),
|
|
|
|
|
style: const TextStyle(fontFamily: 'RobotoMono'),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
hint,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: theme.textTheme.bodySmall,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
@ -254,29 +247,28 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
return Tooltip(
|
|
|
|
|
message: label,
|
|
|
|
|
waitDuration: const Duration(days: 1), // don't show on hover
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: padding,
|
|
|
|
|
child: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')),
|
|
|
|
|
child: ListTile(
|
|
|
|
|
onTap: () => onSelected(suggestion),
|
|
|
|
|
title: Text(label, style: const TextStyle(fontFamily: 'RobotoMono')),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (suggestion['type'] == 'emote') {
|
|
|
|
|
return Container(
|
|
|
|
|
padding: padding,
|
|
|
|
|
child: Row(
|
|
|
|
|
return ListTile(
|
|
|
|
|
onTap: () => onSelected(suggestion),
|
|
|
|
|
leading: MxcImage(
|
|
|
|
|
// ensure proper ordering ...
|
|
|
|
|
key: ValueKey(suggestion['name']),
|
|
|
|
|
uri: suggestion['mxc'] is String
|
|
|
|
|
? Uri.parse(suggestion['mxc'] ?? '')
|
|
|
|
|
: null,
|
|
|
|
|
width: size,
|
|
|
|
|
height: size,
|
|
|
|
|
isThumbnail: false,
|
|
|
|
|
),
|
|
|
|
|
title: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: <Widget>[
|
|
|
|
|
MxcImage(
|
|
|
|
|
// ensure proper ordering ...
|
|
|
|
|
key: ValueKey(suggestion['name']),
|
|
|
|
|
uri: suggestion['mxc'] is String
|
|
|
|
|
? Uri.parse(suggestion['mxc'] ?? '')
|
|
|
|
|
: null,
|
|
|
|
|
width: size,
|
|
|
|
|
height: size,
|
|
|
|
|
isThumbnail: false,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Text(suggestion['name']!),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
@ -302,28 +294,22 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
}
|
|
|
|
|
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
|
|
|
|
|
final url = Uri.parse(suggestion['avatar_url'] ?? '');
|
|
|
|
|
return Container(
|
|
|
|
|
padding: padding,
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: <Widget>[
|
|
|
|
|
Avatar(
|
|
|
|
|
mxContent: url,
|
|
|
|
|
name: suggestion.tryGet<String>('displayname') ??
|
|
|
|
|
suggestion.tryGet<String>('mxid'),
|
|
|
|
|
size: size,
|
|
|
|
|
client: client,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Text(suggestion['displayname'] ?? suggestion['mxid']!),
|
|
|
|
|
],
|
|
|
|
|
return ListTile(
|
|
|
|
|
onTap: () => onSelected(suggestion),
|
|
|
|
|
leading: Avatar(
|
|
|
|
|
mxContent: url,
|
|
|
|
|
name: suggestion.tryGet<String>('displayname') ??
|
|
|
|
|
suggestion.tryGet<String>('mxid'),
|
|
|
|
|
size: size,
|
|
|
|
|
client: client,
|
|
|
|
|
),
|
|
|
|
|
title: Text(suggestion['displayname'] ?? suggestion['mxid']!),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void insertSuggestion(_, Map<String, String?> suggestion) {
|
|
|
|
|
String insertSuggestion(Map<String, String?> suggestion) {
|
|
|
|
|
final replaceText =
|
|
|
|
|
controller!.text.substring(0, controller!.selection.baseOffset);
|
|
|
|
|
var startText = '';
|
|
|
|
|
@ -384,27 +370,18 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
(Match m) => '${m[1]}$insertText',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (insertText.isNotEmpty && startText.isNotEmpty) {
|
|
|
|
|
controller!.text = startText + afterText;
|
|
|
|
|
controller!.selection = TextSelection(
|
|
|
|
|
baseOffset: startText.length,
|
|
|
|
|
extentOffset: startText.length,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return startText + afterText;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return TypeAheadField<Map<String, String?>>(
|
|
|
|
|
direction: VerticalDirection.up,
|
|
|
|
|
hideOnEmpty: true,
|
|
|
|
|
hideOnLoading: true,
|
|
|
|
|
controller: controller,
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
return Autocomplete<Map<String, String?>>(
|
|
|
|
|
focusNode: focusNode,
|
|
|
|
|
hideOnSelect: false,
|
|
|
|
|
debounceDuration: const Duration(milliseconds: 50),
|
|
|
|
|
// show suggestions after 50ms idle time (default is 300)
|
|
|
|
|
builder: (context, controller, focusNode) => TextField(
|
|
|
|
|
textEditingController: controller,
|
|
|
|
|
optionsBuilder: getSuggestions,
|
|
|
|
|
fieldViewBuilder: (context, controller, focusNode, _) => TextField(
|
|
|
|
|
controller: controller,
|
|
|
|
|
focusNode: focusNode,
|
|
|
|
|
readOnly: readOnly,
|
|
|
|
|
@ -448,17 +425,27 @@ class InputBar extends StatelessWidget {
|
|
|
|
|
},
|
|
|
|
|
textCapitalization: TextCapitalization.sentences,
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
suggestionsCallback: getSuggestions,
|
|
|
|
|
itemBuilder: (c, s) => buildSuggestion(c, s, Matrix.of(context).client),
|
|
|
|
|
onSelected: (Map<String, String?> suggestion) =>
|
|
|
|
|
insertSuggestion(context, suggestion),
|
|
|
|
|
errorBuilder: (BuildContext context, Object? error) =>
|
|
|
|
|
const SizedBox.shrink(),
|
|
|
|
|
loadingBuilder: (BuildContext context) => const SizedBox.shrink(),
|
|
|
|
|
// fix loading briefly flickering a dark box
|
|
|
|
|
emptyBuilder: (BuildContext context) =>
|
|
|
|
|
const SizedBox.shrink(), // fix loading briefly showing no suggestions
|
|
|
|
|
optionsViewBuilder: (c, onSelected, s) {
|
|
|
|
|
final suggestions = s.toList();
|
|
|
|
|
return Material(
|
|
|
|
|
elevation: theme.appBarTheme.scrolledUnderElevation ?? 4,
|
|
|
|
|
shadowColor: theme.appBarTheme.shadowColor,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
|
|
|
|
clipBehavior: Clip.hardEdge,
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
shrinkWrap: true,
|
|
|
|
|
itemCount: suggestions.length,
|
|
|
|
|
itemBuilder: (context, i) => buildSuggestion(
|
|
|
|
|
c,
|
|
|
|
|
suggestions[i],
|
|
|
|
|
onSelected,
|
|
|
|
|
Matrix.of(context).client,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
displayStringForOption: insertSuggestion,
|
|
|
|
|
optionsViewOpenDirection: OptionsViewOpenDirection.up,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|