Better tags input

This commit is contained in:
LittleSheep 2024-07-31 02:00:03 +08:00
parent a16ff1b9a1
commit b70d3795d1
13 changed files with 289 additions and 111 deletions

View File

@ -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,8 +102,10 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
for (var idx = 0; idx < _periods.length; idx++) {
await _periods[idx].action();
if (_isErrored) break;
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(

View File

@ -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();
}
}

View File

@ -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),
),

View File

@ -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),
),
);
}

View File

@ -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',
};

View File

@ -92,6 +92,7 @@ const i18nSimplifiedChinese = {
'unpinPost': '取消置顶本帖',
'postRestoreFromLocal': '内容从本地暂存回复',
'postAutoSaveAt': '已自动保存于 @date',
'postCategoriesAndTags': '分类与标签',
'postVisibility': '帖子可见性',
'postVisibilityAll': '所有人可见',
'postVisibilityFriends': '仅好友可见',

View File

@ -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,
);
},
),
);
}
}

View File

@ -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(

View 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),
),
],
);
}
}

View File

@ -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();

View 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();
}

View File

@ -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:

View File

@ -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: