diff --git a/assets/translations/en.json b/assets/translations/en.json index 664ddfa..2378daa 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -103,6 +103,7 @@ "fieldPostContent": "What happened?!", "fieldPostTitle": "Title", "fieldPostDescription": "Description", + "fieldPostTags": "Tags", "postPublish": "Publish", "postPosted": "Post has been posted.", "postPublishedAt": "Published At", diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 8665731..997fe5d 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -103,6 +103,7 @@ "fieldPostContent": "发生什么事了?!", "fieldPostTitle": "标题", "fieldPostDescription": "描述", + "fieldPostTags": "标签", "postPublish": "发布", "postPublishedAt": "发布于", "postPublishedUntil": "取消发布于", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6337af3..8924a71 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -107,7 +107,7 @@ PODS: - flutter_udid (0.0.1): - Flutter - SAMKeychain - - flutter_webrtc (0.11.8): + - flutter_webrtc (0.12.2): - Flutter - WebRTC-SDK (= 125.6422.05) - GoogleAppMeasurement (11.4.0): @@ -341,7 +341,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 - flutter_webrtc: 4f730f3d58a28b0afdea039c8bf4a0f616a6b20c + flutter_webrtc: 043d1b47e9795158dd97dc84f1c152cd0e98b93b GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 0c6b27c..818889a 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -172,6 +172,7 @@ class PostWriteController extends ChangeNotifier { SnPublisher? publisher; SnPost? editingPost, repostingPost, replyingPost; + List tags = List.empty(); List attachments = List.empty(growable: true); DateTime? publishedAt, publishedUntil; @@ -195,6 +196,7 @@ class PostWriteController extends ChangeNotifier { contentController.text = post.body['content'] ?? ''; publishedAt = post.publishedAt; publishedUntil = post.publishedUntil; + tags = List.from(post.tags.map((ele) => ele.alias)); attachments.addAll( post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [], ); @@ -290,6 +292,7 @@ class PostWriteController extends ChangeNotifier { .where((e) => e.attachment != null) .map((e) => e.attachment!.rid) .toList(), + 'tags': tags.map((ele) => {'alias': ele}).toList(), if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) @@ -351,6 +354,11 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setTags(List value) { + tags = value; + notifyListeners(); + } + void setIsBusy(bool value) { isBusy = value; notifyListeners(); diff --git a/lib/providers/post.dart b/lib/providers/post.dart index 9c33499..34278f5 100644 --- a/lib/providers/post.dart +++ b/lib/providers/post.dart @@ -112,7 +112,7 @@ class SnPostContentProvider { Future getPost(dynamic id) async { final resp = await _sn.client.get('/cgi/co/posts/$id'); final out = _preloadRelatedDataSingle( - SnPost.fromJson(resp.data['data']), + SnPost.fromJson(resp.data), ); return out; } diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 41fb1cd..e73cf99 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -141,7 +141,7 @@ class _PostEditorScreenState extends State { ), const TextSpan(text: '\n'), TextSpan( - text: PostWriteController.kTitleMap[widget.mode]!, + text: PostWriteController.kTitleMap[widget.mode]!.tr(), style: Theme.of(context) .textTheme .bodySmall! diff --git a/lib/types/post.dart b/lib/types/post.dart index 461fa37..7555b07 100644 --- a/lib/types/post.dart +++ b/lib/types/post.dart @@ -18,7 +18,7 @@ class SnPost with _$SnPost { required String language, required String? alias, required String? aliasPrefix, - @Default([]) List tags, + @Default([]) List tags, @Default([]) List categories, required List? replies, required int? replyId, @@ -50,6 +50,23 @@ class SnPost with _$SnPost { }; } +@freezed +class SnPostTag with _$SnPostTag { + const factory SnPostTag({ + required int id, + required DateTime createdAt, + required DateTime updatedAt, + required dynamic deletedAt, + required String alias, + required String name, + required String description, + required dynamic posts, + }) = _SnPostTag; + + factory SnPostTag.fromJson(Map json) => + _$SnPostTagFromJson(json); +} + @freezed class SnPostPreload with _$SnPostPreload { const factory SnPostPreload({ diff --git a/lib/types/post.freezed.dart b/lib/types/post.freezed.dart index 2b1f72f..49869c5 100644 --- a/lib/types/post.freezed.dart +++ b/lib/types/post.freezed.dart @@ -29,7 +29,7 @@ mixin _$SnPost { String get language => throw _privateConstructorUsedError; String? get alias => throw _privateConstructorUsedError; String? get aliasPrefix => throw _privateConstructorUsedError; - List get tags => throw _privateConstructorUsedError; + List get tags => throw _privateConstructorUsedError; List get categories => throw _privateConstructorUsedError; List? get replies => throw _privateConstructorUsedError; int? get replyId => throw _privateConstructorUsedError; @@ -76,7 +76,7 @@ abstract class $SnPostCopyWith<$Res> { String language, String? alias, String? aliasPrefix, - List tags, + List tags, List categories, List? replies, int? replyId, @@ -193,7 +193,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost> tags: null == tags ? _value.tags : tags // ignore: cast_nullable_to_non_nullable - as List, + as List, categories: null == categories ? _value.categories : categories // ignore: cast_nullable_to_non_nullable @@ -361,7 +361,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> { String language, String? alias, String? aliasPrefix, - List tags, + List tags, List categories, List? replies, int? replyId, @@ -481,7 +481,7 @@ class __$$SnPostImplCopyWithImpl<$Res> tags: null == tags ? _value._tags : tags // ignore: cast_nullable_to_non_nullable - as List, + as List, categories: null == categories ? _value._categories : categories // ignore: cast_nullable_to_non_nullable @@ -583,7 +583,7 @@ class _$SnPostImpl extends _SnPost { required this.language, required this.alias, required this.aliasPrefix, - final List tags = const [], + final List tags = const [], final List categories = const [], required final List? replies, required this.replyId, @@ -640,10 +640,10 @@ class _$SnPostImpl extends _SnPost { final String? alias; @override final String? aliasPrefix; - final List _tags; + final List _tags; @override @JsonKey() - List get tags { + List get tags { if (_tags is EqualUnmodifiableListView) return _tags; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_tags); @@ -852,7 +852,7 @@ abstract class _SnPost extends SnPost { required final String language, required final String? alias, required final String? aliasPrefix, - final List tags, + final List tags, final List categories, required final List? replies, required final int? replyId, @@ -897,7 +897,7 @@ abstract class _SnPost extends SnPost { @override String? get aliasPrefix; @override - List get tags; + List get tags; @override List get categories; @override @@ -949,6 +949,310 @@ abstract class _SnPost extends SnPost { throw _privateConstructorUsedError; } +SnPostTag _$SnPostTagFromJson(Map json) { + return _SnPostTag.fromJson(json); +} + +/// @nodoc +mixin _$SnPostTag { + int get id => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime get updatedAt => throw _privateConstructorUsedError; + dynamic get deletedAt => throw _privateConstructorUsedError; + String get alias => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + dynamic get posts => throw _privateConstructorUsedError; + + /// Serializes this SnPostTag to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SnPostTag + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SnPostTagCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SnPostTagCopyWith<$Res> { + factory $SnPostTagCopyWith(SnPostTag value, $Res Function(SnPostTag) then) = + _$SnPostTagCopyWithImpl<$Res, SnPostTag>; + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + dynamic deletedAt, + String alias, + String name, + String description, + dynamic posts}); +} + +/// @nodoc +class _$SnPostTagCopyWithImpl<$Res, $Val extends SnPostTag> + implements $SnPostTagCopyWith<$Res> { + _$SnPostTagCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SnPostTag + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? alias = null, + Object? name = null, + Object? description = null, + Object? posts = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as dynamic, + alias: null == alias + ? _value.alias + : alias // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + posts: freezed == posts + ? _value.posts + : posts // ignore: cast_nullable_to_non_nullable + as dynamic, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SnPostTagImplCopyWith<$Res> + implements $SnPostTagCopyWith<$Res> { + factory _$$SnPostTagImplCopyWith( + _$SnPostTagImpl value, $Res Function(_$SnPostTagImpl) then) = + __$$SnPostTagImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + dynamic deletedAt, + String alias, + String name, + String description, + dynamic posts}); +} + +/// @nodoc +class __$$SnPostTagImplCopyWithImpl<$Res> + extends _$SnPostTagCopyWithImpl<$Res, _$SnPostTagImpl> + implements _$$SnPostTagImplCopyWith<$Res> { + __$$SnPostTagImplCopyWithImpl( + _$SnPostTagImpl _value, $Res Function(_$SnPostTagImpl) _then) + : super(_value, _then); + + /// Create a copy of SnPostTag + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? alias = null, + Object? name = null, + Object? description = null, + Object? posts = freezed, + }) { + return _then(_$SnPostTagImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as dynamic, + alias: null == alias + ? _value.alias + : alias // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + posts: freezed == posts + ? _value.posts + : posts // ignore: cast_nullable_to_non_nullable + as dynamic, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SnPostTagImpl implements _SnPostTag { + const _$SnPostTagImpl( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.deletedAt, + required this.alias, + required this.name, + required this.description, + required this.posts}); + + factory _$SnPostTagImpl.fromJson(Map json) => + _$$SnPostTagImplFromJson(json); + + @override + final int id; + @override + final DateTime createdAt; + @override + final DateTime updatedAt; + @override + final dynamic deletedAt; + @override + final String alias; + @override + final String name; + @override + final String description; + @override + final dynamic posts; + + @override + String toString() { + return 'SnPostTag(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SnPostTagImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + const DeepCollectionEquality().equals(other.deletedAt, deletedAt) && + (identical(other.alias, alias) || other.alias == alias) && + (identical(other.name, name) || other.name == name) && + (identical(other.description, description) || + other.description == description) && + const DeepCollectionEquality().equals(other.posts, posts)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + createdAt, + updatedAt, + const DeepCollectionEquality().hash(deletedAt), + alias, + name, + description, + const DeepCollectionEquality().hash(posts)); + + /// Create a copy of SnPostTag + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SnPostTagImplCopyWith<_$SnPostTagImpl> get copyWith => + __$$SnPostTagImplCopyWithImpl<_$SnPostTagImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SnPostTagImplToJson( + this, + ); + } +} + +abstract class _SnPostTag implements SnPostTag { + const factory _SnPostTag( + {required final int id, + required final DateTime createdAt, + required final DateTime updatedAt, + required final dynamic deletedAt, + required final String alias, + required final String name, + required final String description, + required final dynamic posts}) = _$SnPostTagImpl; + + factory _SnPostTag.fromJson(Map json) = + _$SnPostTagImpl.fromJson; + + @override + int get id; + @override + DateTime get createdAt; + @override + DateTime get updatedAt; + @override + dynamic get deletedAt; + @override + String get alias; + @override + String get name; + @override + String get description; + @override + dynamic get posts; + + /// Create a copy of SnPostTag + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SnPostTagImplCopyWith<_$SnPostTagImpl> get copyWith => + throw _privateConstructorUsedError; +} + SnPostPreload _$SnPostPreloadFromJson(Map json) { return _SnPostPreload.fromJson(json); } diff --git a/lib/types/post.g.dart b/lib/types/post.g.dart index 4150142..41b665a 100644 --- a/lib/types/post.g.dart +++ b/lib/types/post.g.dart @@ -18,7 +18,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map json) => _$SnPostImpl( language: json['language'] as String, alias: json['alias'] as String?, aliasPrefix: json['alias_prefix'] as String?, - tags: json['tags'] as List? ?? const [], + tags: (json['tags'] as List?) + ?.map((e) => SnPostTag.fromJson(e as Map)) + .toList() ?? + const [], categories: json['categories'] as List? ?? const [], replies: (json['replies'] as List?) ?.map((e) => SnPost.fromJson(e as Map)) @@ -76,7 +79,7 @@ Map _$$SnPostImplToJson(_$SnPostImpl instance) => 'language': instance.language, 'alias': instance.alias, 'alias_prefix': instance.aliasPrefix, - 'tags': instance.tags, + 'tags': instance.tags.map((e) => e.toJson()).toList(), 'categories': instance.categories, 'replies': instance.replies?.map((e) => e.toJson()).toList(), 'reply_id': instance.replyId, @@ -100,6 +103,30 @@ Map _$$SnPostImplToJson(_$SnPostImpl instance) => 'preload': instance.preload?.toJson(), }; +_$SnPostTagImpl _$$SnPostTagImplFromJson(Map json) => + _$SnPostTagImpl( + id: (json['id'] as num).toInt(), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'], + alias: json['alias'] as String, + name: json['name'] as String, + description: json['description'] as String, + posts: json['posts'], + ); + +Map _$$SnPostTagImplToJson(_$SnPostTagImpl instance) => + { + 'id': instance.id, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt, + 'alias': instance.alias, + 'name': instance.name, + 'description': instance.description, + 'posts': instance.posts, + }; + _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map json) => _$SnPostPreloadImpl( thumbnail: json['thumbnail'] == null diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index a1d31e0..370aed4 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -61,6 +61,8 @@ class PostItem extends StatelessWidget { horizontal: 16, vertical: 4, ), + if (data.tags.isNotEmpty) + _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), ], ), ), @@ -388,6 +390,32 @@ class _PostQuoteContent extends StatelessWidget { } } +class _PostTagsList extends StatelessWidget { + final SnPost data; + const _PostTagsList({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 4, + runSpacing: 4, + children: data.tags + .map( + (ele) => InkWell( + child: Text( + '#${ele.alias}', + style: TextStyle( + decoration: TextDecoration.underline, + ), + ).fontSize(13), + onTap: () {}, + ), + ) + .toList(), + ).opacity(0.8); + } +} + class _PostTruncatedHint extends StatelessWidget { final SnPost data; const _PostTruncatedHint({super.key, required this.data}); diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart index 4bffdfa..602635f 100644 --- a/lib/widgets/post/post_meta_editor.dart +++ b/lib/widgets/post/post_meta_editor.dart @@ -5,6 +5,7 @@ import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/widgets/post/post_tags_field.dart'; class PostMetaEditor extends StatelessWidget { final PostWriteController controller; @@ -69,6 +70,14 @@ class PostMetaEditor extends StatelessWidget { onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ).padding(horizontal: 24), + const Gap(4), + PostTagsField( + initialTags: controller.tags, + labelText: 'fieldPostTags'.tr(), + onUpdate: (value) { + controller.setTags(value); + }, + ).padding(horizontal: 24), const Gap(12), ListTile( leading: const Icon(Symbols.event_available), diff --git a/lib/widgets/post/post_tags_field.dart b/lib/widgets/post/post_tags_field.dart new file mode 100644 index 0000000..a81d1bd --- /dev/null +++ b/lib/widgets/post/post_tags_field.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:provider/provider.dart'; +import 'package:surface/providers/sn_network.dart'; + +class PostTagsField extends StatefulWidget { + final List? initialTags; + final String labelText; + final Function(List) onUpdate; + + const PostTagsField({ + super.key, + this.initialTags, + required this.labelText, + required this.onUpdate, + }); + + @override + State createState() => _PostTagsFieldState(); +} + +class _PostTagsFieldState extends State { + static const List kTagsDividers = [' ', ',']; + + 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 sn = context.read(); + final resp = await sn.client.get( + '/cgi/co/tags?take=10&probe=$_currentSearchProbe', + ); + + if (_currentSearchProbe != probe) { + return null; + } + _currentSearchProbe = null; + + return resp.data.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( + label: Text(widget.labelText), + border: const UnderlineInputBorder(), + 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(right: 8), + 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 Gap(4), + 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(), + ), + ) + : null, + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (value) { + for (final divider in kTagsDividers) { + 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(); +}