From b70d3795d1bef964043b90f8ace03898d0aee381 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 31 Jul 2024 02:00:03 +0800 Subject: [PATCH] :sparkles: Better tags input --- lib/bootstrapper.dart | 9 +- lib/controllers/post_editor_controller.dart | 23 +- lib/screens/posts/post_editor.dart | 7 + lib/theme.dart | 2 +- lib/translations/en_us.dart | 7 +- lib/translations/zh_cn.dart | 1 + lib/widgets/feed/feed_tags_field.dart | 96 --------- lib/widgets/markdown_text_content.dart | 1 - .../editor/post_editor_categories_tags.dart | 37 ++++ .../posts/editor/post_editor_visibility.dart | 4 +- lib/widgets/posts/editor/post_tags_field.dart | 204 ++++++++++++++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 13 files changed, 289 insertions(+), 111 deletions(-) delete mode 100644 lib/widgets/feed/feed_tags_field.dart create mode 100644 lib/widgets/posts/editor/post_editor_categories_tags.dart create mode 100644 lib/widgets/posts/editor/post_tags_field.dart diff --git a/lib/bootstrapper.dart b/lib/bootstrapper.dart index cc29ca8..e1dac38 100644 --- a/lib/bootstrapper.dart +++ b/lib/bootstrapper.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:solian/exts.dart'; import 'package:solian/providers/auth.dart'; @@ -101,7 +102,9 @@ class _BootstrapperShellState extends State { for (var idx = 0; idx < _periods.length; idx++) { await _periods[idx].action(); if (_isErrored) break; - setState(() => _periodCursor++); + if (_periodCursor < _periods.length - 1) { + setState(() => _periodCursor++); + } } } finally { setState(() => _isBusy = false); @@ -127,7 +130,9 @@ class _BootstrapperShellState extends State { height: 280, child: Align( 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( diff --git a/lib/controllers/post_editor_controller.dart b/lib/controllers/post_editor_controller.dart index 8a9f7c6..e024772 100644 --- a/lib/controllers/post_editor_controller.dart +++ b/lib/controllers/post_editor_controller.dart @@ -6,9 +6,9 @@ import 'package:get/get.dart'; import 'package:solian/models/post.dart'; import 'package:solian/models/realm.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_visibility.dart'; -import 'package:textfield_tags/textfield_tags.dart'; import 'package:shared_preferences/shared_preferences.dart'; class PostEditorController extends GetxController { @@ -17,7 +17,6 @@ class PostEditorController extends GetxController { final titleController = TextEditingController(); final descriptionController = TextEditingController(); final contentController = TextEditingController(); - final tagController = StringTagController(); RxInt mode = 0.obs; RxInt contentLength = 0.obs; @@ -27,6 +26,7 @@ class PostEditorController extends GetxController { Rx repostTo = Rx(null); Rx realmZone = Rx(null); RxList attachments = RxList.empty(growable: true); + RxList tags = RxList.empty(growable: true); RxList visibleUsers = RxList.empty(growable: true); RxList invisibleUsers = RxList.empty(growable: true); @@ -79,6 +79,15 @@ class PostEditorController extends GetxController { ); } + Future editCategoriesAndTags(BuildContext context) { + return showDialog( + context: context, + builder: (context) => PostEditorCategoriesDialog( + controller: this, + ), + ); + } + Future editAttachment(BuildContext context) { return showModalBottomSheet( context: context, @@ -127,8 +136,8 @@ class PostEditorController extends GetxController { titleController.clear(); descriptionController.clear(); contentController.clear(); - tagController.clearTags(); attachments.clear(); + tags.clear(); visibleUsers.clear(); invisibleUsers.clear(); visibility.value = 0; @@ -206,8 +215,7 @@ class PostEditorController extends GetxController { 'title': title, 'description': description, 'content': contentController.text, - 'tags': tagController.getTags?.map((x) => {'alias': x}).toList() ?? - List.empty(), + 'tags': tags, 'attachments': attachments, 'visible_users': visibleUsers, 'invisible_users': invisibleUsers, @@ -259,7 +267,7 @@ class PostEditorController extends GetxController { descriptionController.text.isNotEmpty, contentController.text.isNotEmpty, attachments.isNotEmpty, - tagController.getTags?.isNotEmpty ?? false, + tags.isNotEmpty ].any((x) => x); } @@ -267,8 +275,9 @@ class PostEditorController extends GetxController { void dispose() { _saveTimer?.cancel(); + titleController.dispose(); + descriptionController.dispose(); contentController.dispose(); - tagController.dispose(); super.dispose(); } } diff --git a/lib/screens/posts/post_editor.dart b/lib/screens/posts/post_editor.dart index 33cadb2..d9c1b10 100644 --- a/lib/screens/posts/post_editor.dart +++ b/lib/screens/posts/post_editor.dart @@ -382,6 +382,13 @@ class _PostPublishScreenState extends State { _editorController.editAttachment(context); }, ), + IconButton( + icon: const Icon(Icons.tag), + color: Theme.of(context).colorScheme.primary, + onPressed: () { + _editorController.editCategoriesAndTags(context); + }, + ), ], ).paddingSymmetric(horizontal: 6, vertical: 8), ), diff --git a/lib/theme.dart b/lib/theme.dart index ccf149b..552d761 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -33,7 +33,7 @@ abstract class SolianTheme { useMaterial3: true, colorScheme: ColorScheme.fromSeed( brightness: brightness, - seedColor: const Color.fromRGBO(103, 96, 193, 1), + seedColor: const Color.fromRGBO(154, 98, 91, 1), ), ); } diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 61078c7..143b33e 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -98,6 +98,7 @@ const i18nEnglish = { 'unpinPost': 'Unpin this post', 'postRestoreFromLocal': 'Restore from local', 'postAutoSaveAt': 'Auto saved at @date', + 'postCategoriesAndTags': 'Categories n\' Tags', 'postVisibility': 'Visibility', 'postVisibilityAll': 'Everyone', 'postVisibilityFriends': 'Friends', @@ -293,12 +294,14 @@ const i18nEnglish = { 'accountStatusNeutral': 'Neutral', 'accountStatusPositive': 'Positive', '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', 'bsAuthorizing': 'Authorizing', 'bsEstablishingConn': 'Establishing Connection', 'bsPreparingData': 'Preparing User Data', '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', }; diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index afd27aa..b1a999d 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -92,6 +92,7 @@ const i18nSimplifiedChinese = { 'unpinPost': '取消置顶本帖', 'postRestoreFromLocal': '内容从本地暂存回复', 'postAutoSaveAt': '已自动保存于 @date', + 'postCategoriesAndTags': '分类与标签', 'postVisibility': '帖子可见性', 'postVisibilityAll': '所有人可见', 'postVisibilityFriends': '仅好友可见', diff --git a/lib/widgets/feed/feed_tags_field.dart b/lib/widgets/feed/feed_tags_field.dart deleted file mode 100644 index 6ea6ba4..0000000 --- a/lib/widgets/feed/feed_tags_field.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:textfield_tags/textfield_tags.dart'; - -class TagsField extends StatelessWidget { - final List? initialTags; - final String hintText; - - const TagsField({ - super.key, - this.initialTags, - required this.hintText, - required StringTagController tagsController, - }) : _tagsController = tagsController; - - final StringTagController _tagsController; - - @override - Widget build(BuildContext context) { - return Container( - height: 48, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: TextFieldTags( - 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, - ); - }, - ), - ); - } -} diff --git a/lib/widgets/markdown_text_content.dart b/lib/widgets/markdown_text_content.dart index d51c52b..198a9eb 100644 --- a/lib/widgets/markdown_text_content.dart +++ b/lib/widgets/markdown_text_content.dart @@ -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( diff --git a/lib/widgets/posts/editor/post_editor_categories_tags.dart b/lib/widgets/posts/editor/post_editor_categories_tags.dart new file mode 100644 index 0000000..88c5f40 --- /dev/null +++ b/lib/widgets/posts/editor/post_editor_categories_tags.dart @@ -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), + ), + ], + ); + } +} diff --git a/lib/widgets/posts/editor/post_editor_visibility.dart b/lib/widgets/posts/editor/post_editor_visibility.dart index e9b4713..973b5fa 100644 --- a/lib/widgets/posts/editor/post_editor_visibility.dart +++ b/lib/widgets/posts/editor/post_editor_visibility.dart @@ -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(); diff --git a/lib/widgets/posts/editor/post_tags_field.dart b/lib/widgets/posts/editor/post_tags_field.dart new file mode 100644 index 0000000..fce5476 --- /dev/null +++ b/lib/widgets/posts/editor/post_tags_field.dart @@ -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? initialTags; + final String hintText; + final Function(List) onUpdate; + + const TagsField({ + super.key, + this.initialTags, + required this.hintText, + required this.onUpdate, + }); + + @override + State createState() => _TagsFieldState(); +} + +class _TagsFieldState extends State { + static final List _dividers = [' ', ',']; + + late final _Debounceable?, String> _debouncedSearch; + + final List _currentTags = List.empty(growable: true); + + String? _currentSearchProbe; + List _lastAutocompleteResult = List.empty(); + TextEditingController? _textEditingController; + + Future?> _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(); + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce?, String>(_searchTags); + if (widget.initialTags != null) { + _currentTags.addAll(widget.initialTags!); + } + } + + @override + Widget build(BuildContext context) { + return Autocomplete( + 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 = Future Function(T parameter); + +_Debounceable _debounce(_Debounceable 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 _completer = Completer(); + + void _onComplete() { + _completer.complete(); + } + + Future get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +class _CancelException implements Exception { + const _CancelException(); +} diff --git a/pubspec.lock b/pubspec.lock index 10760c4..5ada671 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 97683e1..a22fcb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: flutter_cache_manager: ^3.3.3 flutter_markdown_selectionarea: ^0.6.17+1 shared_preferences: ^2.2.3 + easy_debounce: ^2.0.3 dev_dependencies: flutter_test: