✨ Better tags input
This commit is contained in:
@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
|
||||
class TagsField extends StatelessWidget {
|
||||
final List<String>? initialTags;
|
||||
final String hintText;
|
||||
|
||||
const TagsField({
|
||||
super.key,
|
||||
this.initialTags,
|
||||
required this.hintText,
|
||||
required StringTagController<String> tagsController,
|
||||
}) : _tagsController = tagsController;
|
||||
|
||||
final StringTagController<String> _tagsController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: TextFieldTags<String>(
|
||||
initialTags: initialTags,
|
||||
letterCase: LetterCase.small,
|
||||
textfieldTagsController: _tagsController,
|
||||
textSeparators: const [' ', ','],
|
||||
inputFieldBuilder: (context, inputFieldValues) {
|
||||
return TextField(
|
||||
controller: inputFieldValues.textEditingController,
|
||||
focusNode: inputFieldValues.focusNode,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
prefixIconConstraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.8,
|
||||
),
|
||||
prefixIcon: inputFieldValues.tags.isNotEmpty
|
||||
? SingleChildScrollView(
|
||||
controller: inputFieldValues.tagScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: inputFieldValues.tags.map((String tag) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(20.0),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 10.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
child: Text(
|
||||
'#$tag',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
onTap: () {
|
||||
//print("$tag selected");
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
InkWell(
|
||||
child: const Icon(
|
||||
Icons.cancel,
|
||||
size: 14.0,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
),
|
||||
onTap: () {
|
||||
inputFieldValues.onTagRemoved(tag);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onChanged: inputFieldValues.onTagChanged,
|
||||
onSubmitted: inputFieldValues.onTagSubmitted,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -19,7 +19,6 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: content,
|
||||
padding: EdgeInsets.zero,
|
||||
selectable: isSelectable,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
|
37
lib/widgets/posts/editor/post_editor_categories_tags.dart
Normal file
37
lib/widgets/posts/editor/post_editor_categories_tags.dart
Normal file
@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/controllers/post_editor_controller.dart';
|
||||
import 'package:solian/widgets/posts/editor/post_tags_field.dart';
|
||||
|
||||
class PostEditorCategoriesDialog extends StatelessWidget {
|
||||
final PostEditorController controller;
|
||||
|
||||
const PostEditorCategoriesDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('postCategoriesAndTags'.tr),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TagsField(
|
||||
initialTags:
|
||||
controller.editTo.value?.tags?.map((x) => x.alias).toList(),
|
||||
hintText: 'postTagsPlaceholder'.tr,
|
||||
onUpdate: (value) {
|
||||
controller.tags.value = value;
|
||||
controller.tags.refresh();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -54,8 +54,8 @@ class PostEditorVisibilityDialog extends StatelessWidget {
|
||||
);
|
||||
}),
|
||||
Obx(() {
|
||||
if (controller.visibility.value != 2 &&
|
||||
controller.visibility.value != 3) {
|
||||
if (controller.visibility.value == 2 ||
|
||||
controller.visibility.value == 3) {
|
||||
return const SizedBox(height: 8);
|
||||
}
|
||||
return const SizedBox();
|
||||
|
204
lib/widgets/posts/editor/post_tags_field.dart
Normal file
204
lib/widgets/posts/editor/post_tags_field.dart
Normal file
@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/services.dart';
|
||||
|
||||
class TagsField extends StatefulWidget {
|
||||
final List<String>? initialTags;
|
||||
final String hintText;
|
||||
final Function(List<String>) onUpdate;
|
||||
|
||||
const TagsField({
|
||||
super.key,
|
||||
this.initialTags,
|
||||
required this.hintText,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TagsField> createState() => _TagsFieldState();
|
||||
}
|
||||
|
||||
class _TagsFieldState extends State<TagsField> {
|
||||
static final List<String> _dividers = [' ', ','];
|
||||
|
||||
late final _Debounceable<List<String>?, String> _debouncedSearch;
|
||||
|
||||
final List<String> _currentTags = List.empty(growable: true);
|
||||
|
||||
String? _currentSearchProbe;
|
||||
List<String> _lastAutocompleteResult = List.empty();
|
||||
TextEditingController? _textEditingController;
|
||||
|
||||
Future<List<String>?> _searchTags(String probe) async {
|
||||
_currentSearchProbe = probe;
|
||||
|
||||
final client = ServiceFinder.configureClient('interactive');
|
||||
final resp = await client.get(
|
||||
'/tags?take=10&probe=$_currentSearchProbe',
|
||||
);
|
||||
|
||||
if (_currentSearchProbe != probe) {
|
||||
return null;
|
||||
}
|
||||
_currentSearchProbe = null;
|
||||
|
||||
return resp.body.map((x) => x['alias']).toList().cast<String>();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_debouncedSearch = _debounce<List<String>?, String>(_searchTags);
|
||||
if (widget.initialTags != null) {
|
||||
_currentTags.addAll(widget.initialTags!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Autocomplete<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) async {
|
||||
final result = await _debouncedSearch(textEditingValue.text);
|
||||
if (result == null) {
|
||||
return _lastAutocompleteResult;
|
||||
}
|
||||
_lastAutocompleteResult = result;
|
||||
return result;
|
||||
},
|
||||
onSelected: (String value) {
|
||||
if (value.isEmpty) return;
|
||||
if (!_currentTags.contains(value)) {
|
||||
setState(() => _currentTags.add(value));
|
||||
}
|
||||
_textEditingController?.clear();
|
||||
widget.onUpdate(_currentTags);
|
||||
},
|
||||
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
|
||||
_textEditingController = controller;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: widget.hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIconConstraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
prefixIcon: _currentTags.isNotEmpty
|
||||
? SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _currentTags.map((String tag) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(20.0),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.only(left: 7.5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
child: Text(
|
||||
'#$tag',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
InkWell(
|
||||
child: const Icon(
|
||||
Icons.cancel,
|
||||
size: 14.0,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() => _currentTags.remove(tag));
|
||||
widget.onUpdate(_currentTags);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
).paddingOnly(right: 5),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onChanged: (value) {
|
||||
for (final divider in _dividers) {
|
||||
if (value.endsWith(divider)) {
|
||||
final tagValue = value.substring(0, value.length - 1);
|
||||
if (tagValue.isEmpty) return;
|
||||
if (!_currentTags.contains(tagValue)) {
|
||||
setState(() => _currentTags.add(tagValue));
|
||||
}
|
||||
controller.clear();
|
||||
widget.onUpdate(_currentTags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
onSubmitted();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
|
||||
|
||||
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
|
||||
_DebounceTimer? debounceTimer;
|
||||
|
||||
return (T parameter) async {
|
||||
if (debounceTimer != null && !debounceTimer!.isCompleted) {
|
||||
debounceTimer!.cancel();
|
||||
}
|
||||
debounceTimer = _DebounceTimer();
|
||||
try {
|
||||
await debounceTimer!.future;
|
||||
} catch (error) {
|
||||
if (error is _CancelException) {
|
||||
return null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
return function(parameter);
|
||||
};
|
||||
}
|
||||
|
||||
class _DebounceTimer {
|
||||
_DebounceTimer() {
|
||||
_timer = Timer(const Duration(milliseconds: 500), _onComplete);
|
||||
}
|
||||
|
||||
late final Timer _timer;
|
||||
final Completer<void> _completer = Completer<void>();
|
||||
|
||||
void _onComplete() {
|
||||
_completer.complete();
|
||||
}
|
||||
|
||||
Future<void> get future => _completer.future;
|
||||
|
||||
bool get isCompleted => _completer.isCompleted;
|
||||
|
||||
void cancel() {
|
||||
_timer.cancel();
|
||||
_completer.completeError(const _CancelException());
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelException implements Exception {
|
||||
const _CancelException();
|
||||
}
|
Reference in New Issue
Block a user