✨ Better tags input
This commit is contained in:
parent
a16ff1b9a1
commit
b70d3795d1
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:solian/exts.dart';
|
import 'package:solian/exts.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
@ -101,7 +102,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
|||||||
for (var idx = 0; idx < _periods.length; idx++) {
|
for (var idx = 0; idx < _periods.length; idx++) {
|
||||||
await _periods[idx].action();
|
await _periods[idx].action();
|
||||||
if (_isErrored) break;
|
if (_isErrored) break;
|
||||||
setState(() => _periodCursor++);
|
if (_periodCursor < _periods.length - 1) {
|
||||||
|
setState(() => _periodCursor++);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isBusy = false);
|
setState(() => _isBusy = false);
|
||||||
@ -127,7 +130,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
|||||||
height: 280,
|
height: 280,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Image.asset('assets/logo.png', width: 80, height: 80),
|
child: Image.asset('assets/logo.png', width: 80, height: 80)
|
||||||
|
.animate(onPlay: (c) => c.repeat())
|
||||||
|
.rotate(duration: 850.ms, curve: Curves.easeInOut),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
@ -6,9 +6,9 @@ import 'package:get/get.dart';
|
|||||||
import 'package:solian/models/post.dart';
|
import 'package:solian/models/post.dart';
|
||||||
import 'package:solian/models/realm.dart';
|
import 'package:solian/models/realm.dart';
|
||||||
import 'package:solian/widgets/attachments/attachment_editor.dart';
|
import 'package:solian/widgets/attachments/attachment_editor.dart';
|
||||||
|
import 'package:solian/widgets/posts/editor/post_editor_categories_tags.dart';
|
||||||
import 'package:solian/widgets/posts/editor/post_editor_overview.dart';
|
import 'package:solian/widgets/posts/editor/post_editor_overview.dart';
|
||||||
import 'package:solian/widgets/posts/editor/post_editor_visibility.dart';
|
import 'package:solian/widgets/posts/editor/post_editor_visibility.dart';
|
||||||
import 'package:textfield_tags/textfield_tags.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class PostEditorController extends GetxController {
|
class PostEditorController extends GetxController {
|
||||||
@ -17,7 +17,6 @@ class PostEditorController extends GetxController {
|
|||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final contentController = TextEditingController();
|
final contentController = TextEditingController();
|
||||||
final tagController = StringTagController();
|
|
||||||
|
|
||||||
RxInt mode = 0.obs;
|
RxInt mode = 0.obs;
|
||||||
RxInt contentLength = 0.obs;
|
RxInt contentLength = 0.obs;
|
||||||
@ -27,6 +26,7 @@ class PostEditorController extends GetxController {
|
|||||||
Rx<Post?> repostTo = Rx(null);
|
Rx<Post?> repostTo = Rx(null);
|
||||||
Rx<Realm?> realmZone = Rx(null);
|
Rx<Realm?> realmZone = Rx(null);
|
||||||
RxList<int> attachments = RxList<int>.empty(growable: true);
|
RxList<int> attachments = RxList<int>.empty(growable: true);
|
||||||
|
RxList<String> tags = RxList<String>.empty(growable: true);
|
||||||
|
|
||||||
RxList<int> visibleUsers = RxList.empty(growable: true);
|
RxList<int> visibleUsers = RxList.empty(growable: true);
|
||||||
RxList<int> invisibleUsers = RxList.empty(growable: true);
|
RxList<int> invisibleUsers = RxList.empty(growable: true);
|
||||||
@ -79,6 +79,15 @@ class PostEditorController extends GetxController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> editCategoriesAndTags(BuildContext context) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PostEditorCategoriesDialog(
|
||||||
|
controller: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> editAttachment(BuildContext context) {
|
Future<void> editAttachment(BuildContext context) {
|
||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@ -127,8 +136,8 @@ class PostEditorController extends GetxController {
|
|||||||
titleController.clear();
|
titleController.clear();
|
||||||
descriptionController.clear();
|
descriptionController.clear();
|
||||||
contentController.clear();
|
contentController.clear();
|
||||||
tagController.clearTags();
|
|
||||||
attachments.clear();
|
attachments.clear();
|
||||||
|
tags.clear();
|
||||||
visibleUsers.clear();
|
visibleUsers.clear();
|
||||||
invisibleUsers.clear();
|
invisibleUsers.clear();
|
||||||
visibility.value = 0;
|
visibility.value = 0;
|
||||||
@ -206,8 +215,7 @@ class PostEditorController extends GetxController {
|
|||||||
'title': title,
|
'title': title,
|
||||||
'description': description,
|
'description': description,
|
||||||
'content': contentController.text,
|
'content': contentController.text,
|
||||||
'tags': tagController.getTags?.map((x) => {'alias': x}).toList() ??
|
'tags': tags,
|
||||||
List.empty(),
|
|
||||||
'attachments': attachments,
|
'attachments': attachments,
|
||||||
'visible_users': visibleUsers,
|
'visible_users': visibleUsers,
|
||||||
'invisible_users': invisibleUsers,
|
'invisible_users': invisibleUsers,
|
||||||
@ -259,7 +267,7 @@ class PostEditorController extends GetxController {
|
|||||||
descriptionController.text.isNotEmpty,
|
descriptionController.text.isNotEmpty,
|
||||||
contentController.text.isNotEmpty,
|
contentController.text.isNotEmpty,
|
||||||
attachments.isNotEmpty,
|
attachments.isNotEmpty,
|
||||||
tagController.getTags?.isNotEmpty ?? false,
|
tags.isNotEmpty
|
||||||
].any((x) => x);
|
].any((x) => x);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,8 +275,9 @@ class PostEditorController extends GetxController {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
|
|
||||||
|
titleController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
contentController.dispose();
|
contentController.dispose();
|
||||||
tagController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -382,6 +382,13 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
|
|||||||
_editorController.editAttachment(context);
|
_editorController.editAttachment(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.tag),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
onPressed: () {
|
||||||
|
_editorController.editCategoriesAndTags(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).paddingSymmetric(horizontal: 6, vertical: 8),
|
).paddingSymmetric(horizontal: 6, vertical: 8),
|
||||||
),
|
),
|
||||||
|
@ -33,7 +33,7 @@ abstract class SolianTheme {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
seedColor: const Color.fromRGBO(103, 96, 193, 1),
|
seedColor: const Color.fromRGBO(154, 98, 91, 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -98,6 +98,7 @@ const i18nEnglish = {
|
|||||||
'unpinPost': 'Unpin this post',
|
'unpinPost': 'Unpin this post',
|
||||||
'postRestoreFromLocal': 'Restore from local',
|
'postRestoreFromLocal': 'Restore from local',
|
||||||
'postAutoSaveAt': 'Auto saved at @date',
|
'postAutoSaveAt': 'Auto saved at @date',
|
||||||
|
'postCategoriesAndTags': 'Categories n\' Tags',
|
||||||
'postVisibility': 'Visibility',
|
'postVisibility': 'Visibility',
|
||||||
'postVisibilityAll': 'Everyone',
|
'postVisibilityAll': 'Everyone',
|
||||||
'postVisibilityFriends': 'Friends',
|
'postVisibilityFriends': 'Friends',
|
||||||
@ -293,12 +294,14 @@ const i18nEnglish = {
|
|||||||
'accountStatusNeutral': 'Neutral',
|
'accountStatusNeutral': 'Neutral',
|
||||||
'accountStatusPositive': 'Positive',
|
'accountStatusPositive': 'Positive',
|
||||||
'bsCheckingServer': 'Checking Server Status',
|
'bsCheckingServer': 'Checking Server Status',
|
||||||
'bsCheckingServerFail': 'Unable connect to server, check your network connection',
|
'bsCheckingServerFail':
|
||||||
|
'Unable connect to server, check your network connection',
|
||||||
'bsCheckingServerDown': 'Server currently unavailable, please retry later',
|
'bsCheckingServerDown': 'Server currently unavailable, please retry later',
|
||||||
'bsAuthorizing': 'Authorizing',
|
'bsAuthorizing': 'Authorizing',
|
||||||
'bsEstablishingConn': 'Establishing Connection',
|
'bsEstablishingConn': 'Establishing Connection',
|
||||||
'bsPreparingData': 'Preparing User Data',
|
'bsPreparingData': 'Preparing User Data',
|
||||||
'bsRegisteringPushNotify': 'Enabling Push Notifications',
|
'bsRegisteringPushNotify': 'Enabling Push Notifications',
|
||||||
'postShareContent': '@content\n\n@username on the Solar Network\nCheck it out: @link',
|
'postShareContent':
|
||||||
|
'@content\n\n@username on the Solar Network\nCheck it out: @link',
|
||||||
'postShareSubject': '@username posted a post on the Solar Network',
|
'postShareSubject': '@username posted a post on the Solar Network',
|
||||||
};
|
};
|
||||||
|
@ -92,6 +92,7 @@ const i18nSimplifiedChinese = {
|
|||||||
'unpinPost': '取消置顶本帖',
|
'unpinPost': '取消置顶本帖',
|
||||||
'postRestoreFromLocal': '内容从本地暂存回复',
|
'postRestoreFromLocal': '内容从本地暂存回复',
|
||||||
'postAutoSaveAt': '已自动保存于 @date',
|
'postAutoSaveAt': '已自动保存于 @date',
|
||||||
|
'postCategoriesAndTags': '分类与标签',
|
||||||
'postVisibility': '帖子可见性',
|
'postVisibility': '帖子可见性',
|
||||||
'postVisibilityAll': '所有人可见',
|
'postVisibilityAll': '所有人可见',
|
||||||
'postVisibilityFriends': '仅好友可见',
|
'postVisibilityFriends': '仅好友可见',
|
||||||
|
@ -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(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
data: content,
|
data: content,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
selectable: isSelectable,
|
|
||||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||||
horizontalRuleDecoration: BoxDecoration(
|
horizontalRuleDecoration: BoxDecoration(
|
||||||
border: Border(
|
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(() {
|
Obx(() {
|
||||||
if (controller.visibility.value != 2 &&
|
if (controller.visibility.value == 2 ||
|
||||||
controller.visibility.value != 3) {
|
controller.visibility.value == 3) {
|
||||||
return const SizedBox(height: 8);
|
return const SizedBox(height: 8);
|
||||||
}
|
}
|
||||||
return const SizedBox();
|
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();
|
||||||
|
}
|
@ -337,6 +337,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.9"
|
version: "2.3.9"
|
||||||
|
easy_debounce:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: easy_debounce
|
||||||
|
sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -60,6 +60,7 @@ dependencies:
|
|||||||
flutter_cache_manager: ^3.3.3
|
flutter_cache_manager: ^3.3.3
|
||||||
flutter_markdown_selectionarea: ^0.6.17+1
|
flutter_markdown_selectionarea: ^0.6.17+1
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
|
easy_debounce: ^2.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user