✨ Better tags input
This commit is contained in:
parent
a16ff1b9a1
commit
b70d3795d1
@ -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<BootstrapperShell> {
|
||||
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<BootstrapperShell> {
|
||||
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(
|
||||
|
@ -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<Post?> repostTo = Rx(null);
|
||||
Rx<Realm?> realmZone = Rx(null);
|
||||
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> 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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -382,6 +382,13 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
|
||||
_editorController.editAttachment(context);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tag),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () {
|
||||
_editorController.editCategoriesAndTags(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 6, vertical: 8),
|
||||
),
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -92,6 +92,7 @@ const i18nSimplifiedChinese = {
|
||||
'unpinPost': '取消置顶本帖',
|
||||
'postRestoreFromLocal': '内容从本地暂存回复',
|
||||
'postAutoSaveAt': '已自动保存于 @date',
|
||||
'postCategoriesAndTags': '分类与标签',
|
||||
'postVisibility': '帖子可见性',
|
||||
'postVisibilityAll': '所有人可见',
|
||||
'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(),
|
||||
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();
|
||||
}
|
@ -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:
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user