Post tags

This commit is contained in:
LittleSheep 2024-11-27 00:06:11 +08:00
parent 312d68286e
commit 420588860a
12 changed files with 616 additions and 17 deletions

View File

@ -103,6 +103,7 @@
"fieldPostContent": "What happened?!", "fieldPostContent": "What happened?!",
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"fieldPostTags": "Tags",
"postPublish": "Publish", "postPublish": "Publish",
"postPosted": "Post has been posted.", "postPosted": "Post has been posted.",
"postPublishedAt": "Published At", "postPublishedAt": "Published At",

View File

@ -103,6 +103,7 @@
"fieldPostContent": "发生什么事了?!", "fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题", "fieldPostTitle": "标题",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "标签",
"postPublish": "发布", "postPublish": "发布",
"postPublishedAt": "发布于", "postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于", "postPublishedUntil": "取消发布于",

View File

@ -107,7 +107,7 @@ PODS:
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
- flutter_webrtc (0.11.8): - flutter_webrtc (0.12.2):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.05) - WebRTC-SDK (= 125.6422.05)
- GoogleAppMeasurement (11.4.0): - GoogleAppMeasurement (11.4.0):
@ -341,7 +341,7 @@ SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_webrtc: 4f730f3d58a28b0afdea039c8bf4a0f616a6b20c flutter_webrtc: 043d1b47e9795158dd97dc84f1c152cd0e98b93b
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d

View File

@ -172,6 +172,7 @@ class PostWriteController extends ChangeNotifier {
SnPublisher? publisher; SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost; SnPost? editingPost, repostingPost, replyingPost;
List<String> tags = List.empty();
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
@ -195,6 +196,7 @@ class PostWriteController extends ChangeNotifier {
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
tags = List.from(post.tags.map((ele) => ele.alias));
attachments.addAll( attachments.addAll(
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [], post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [],
); );
@ -290,6 +292,7 @@ class PostWriteController extends ChangeNotifier {
.where((e) => e.attachment != null) .where((e) => e.attachment != null)
.map((e) => e.attachment!.rid) .map((e) => e.attachment!.rid)
.toList(), .toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
if (publishedAt != null) if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(), 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) if (publishedUntil != null)
@ -351,6 +354,11 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setTags(List<String> value) {
tags = value;
notifyListeners();
}
void setIsBusy(bool value) { void setIsBusy(bool value) {
isBusy = value; isBusy = value;
notifyListeners(); notifyListeners();

View File

@ -112,7 +112,7 @@ class SnPostContentProvider {
Future<SnPost> getPost(dynamic id) async { Future<SnPost> getPost(dynamic id) async {
final resp = await _sn.client.get('/cgi/co/posts/$id'); final resp = await _sn.client.get('/cgi/co/posts/$id');
final out = _preloadRelatedDataSingle( final out = _preloadRelatedDataSingle(
SnPost.fromJson(resp.data['data']), SnPost.fromJson(resp.data),
); );
return out; return out;
} }

View File

@ -141,7 +141,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!, text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodySmall! .bodySmall!

View File

@ -18,7 +18,7 @@ class SnPost with _$SnPost {
required String language, required String language,
required String? alias, required String? alias,
required String? aliasPrefix, required String? aliasPrefix,
@Default([]) List<dynamic> tags, @Default([]) List<SnPostTag> tags,
@Default([]) List<dynamic> categories, @Default([]) List<dynamic> categories,
required List<SnPost>? replies, required List<SnPost>? replies,
required int? replyId, 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<String, Object?> json) =>
_$SnPostTagFromJson(json);
}
@freezed @freezed
class SnPostPreload with _$SnPostPreload { class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({ const factory SnPostPreload({

View File

@ -29,7 +29,7 @@ mixin _$SnPost {
String get language => throw _privateConstructorUsedError; String get language => throw _privateConstructorUsedError;
String? get alias => throw _privateConstructorUsedError; String? get alias => throw _privateConstructorUsedError;
String? get aliasPrefix => throw _privateConstructorUsedError; String? get aliasPrefix => throw _privateConstructorUsedError;
List<dynamic> get tags => throw _privateConstructorUsedError; List<SnPostTag> get tags => throw _privateConstructorUsedError;
List<dynamic> get categories => throw _privateConstructorUsedError; List<dynamic> get categories => throw _privateConstructorUsedError;
List<SnPost>? get replies => throw _privateConstructorUsedError; List<SnPost>? get replies => throw _privateConstructorUsedError;
int? get replyId => throw _privateConstructorUsedError; int? get replyId => throw _privateConstructorUsedError;
@ -76,7 +76,7 @@ abstract class $SnPostCopyWith<$Res> {
String language, String language,
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<dynamic> tags, List<SnPostTag> tags,
List<dynamic> categories, List<dynamic> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
@ -193,7 +193,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
tags: null == tags tags: null == tags
? _value.tags ? _value.tags
: tags // ignore: cast_nullable_to_non_nullable : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostTag>,
categories: null == categories categories: null == categories
? _value.categories ? _value.categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
@ -361,7 +361,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String language, String language,
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<dynamic> tags, List<SnPostTag> tags,
List<dynamic> categories, List<dynamic> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
@ -481,7 +481,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
tags: null == tags tags: null == tags
? _value._tags ? _value._tags
: tags // ignore: cast_nullable_to_non_nullable : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostTag>,
categories: null == categories categories: null == categories
? _value._categories ? _value._categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
@ -583,7 +583,7 @@ class _$SnPostImpl extends _SnPost {
required this.language, required this.language,
required this.alias, required this.alias,
required this.aliasPrefix, required this.aliasPrefix,
final List<dynamic> tags = const [], final List<SnPostTag> tags = const [],
final List<dynamic> categories = const [], final List<dynamic> categories = const [],
required final List<SnPost>? replies, required final List<SnPost>? replies,
required this.replyId, required this.replyId,
@ -640,10 +640,10 @@ class _$SnPostImpl extends _SnPost {
final String? alias; final String? alias;
@override @override
final String? aliasPrefix; final String? aliasPrefix;
final List<dynamic> _tags; final List<SnPostTag> _tags;
@override @override
@JsonKey() @JsonKey()
List<dynamic> get tags { List<SnPostTag> get tags {
if (_tags is EqualUnmodifiableListView) return _tags; if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags); return EqualUnmodifiableListView(_tags);
@ -852,7 +852,7 @@ abstract class _SnPost extends SnPost {
required final String language, required final String language,
required final String? alias, required final String? alias,
required final String? aliasPrefix, required final String? aliasPrefix,
final List<dynamic> tags, final List<SnPostTag> tags,
final List<dynamic> categories, final List<dynamic> categories,
required final List<SnPost>? replies, required final List<SnPost>? replies,
required final int? replyId, required final int? replyId,
@ -897,7 +897,7 @@ abstract class _SnPost extends SnPost {
@override @override
String? get aliasPrefix; String? get aliasPrefix;
@override @override
List<dynamic> get tags; List<SnPostTag> get tags;
@override @override
List<dynamic> get categories; List<dynamic> get categories;
@override @override
@ -949,6 +949,310 @@ abstract class _SnPost extends SnPost {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
SnPostTag _$SnPostTagFromJson(Map<String, dynamic> 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<String, dynamic> 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<SnPostTag> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) { SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
return _SnPostPreload.fromJson(json); return _SnPostPreload.fromJson(json);
} }

View File

@ -18,7 +18,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
language: json['language'] as String, language: json['language'] as String,
alias: json['alias'] as String?, alias: json['alias'] as String?,
aliasPrefix: json['alias_prefix'] as String?, aliasPrefix: json['alias_prefix'] as String?,
tags: json['tags'] as List<dynamic>? ?? const [], tags: (json['tags'] as List<dynamic>?)
?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
categories: json['categories'] as List<dynamic>? ?? const [], categories: json['categories'] as List<dynamic>? ?? const [],
replies: (json['replies'] as List<dynamic>?) replies: (json['replies'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
@ -76,7 +79,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'language': instance.language, 'language': instance.language,
'alias': instance.alias, 'alias': instance.alias,
'alias_prefix': instance.aliasPrefix, 'alias_prefix': instance.aliasPrefix,
'tags': instance.tags, 'tags': instance.tags.map((e) => e.toJson()).toList(),
'categories': instance.categories, 'categories': instance.categories,
'replies': instance.replies?.map((e) => e.toJson()).toList(), 'replies': instance.replies?.map((e) => e.toJson()).toList(),
'reply_id': instance.replyId, 'reply_id': instance.replyId,
@ -100,6 +103,30 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'preload': instance.preload?.toJson(), 'preload': instance.preload?.toJson(),
}; };
_$SnPostTagImpl _$$SnPostTagImplFromJson(Map<String, dynamic> 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<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
<String, dynamic>{
'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<String, dynamic> json) => _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
_$SnPostPreloadImpl( _$SnPostPreloadImpl(
thumbnail: json['thumbnail'] == null thumbnail: json['thumbnail'] == null

View File

@ -61,6 +61,8 @@ class PostItem extends StatelessWidget {
horizontal: 16, horizontal: 16,
vertical: 4, 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 { class _PostTruncatedHint extends StatelessWidget {
final SnPost data; final SnPost data;
const _PostTruncatedHint({super.key, required this.data}); const _PostTruncatedHint({super.key, required this.data});

View File

@ -5,6 +5,7 @@ import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/widgets/post/post_tags_field.dart';
class PostMetaEditor extends StatelessWidget { class PostMetaEditor extends StatelessWidget {
final PostWriteController controller; final PostWriteController controller;
@ -69,6 +70,14 @@ class PostMetaEditor extends StatelessWidget {
onTapOutside: (_) => onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(4),
PostTagsField(
initialTags: controller.tags,
labelText: 'fieldPostTags'.tr(),
onUpdate: (value) {
controller.setTags(value);
},
).padding(horizontal: 24),
const Gap(12), const Gap(12),
ListTile( ListTile(
leading: const Icon(Symbols.event_available), leading: const Icon(Symbols.event_available),

View File

@ -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<String>? initialTags;
final String labelText;
final Function(List<String>) onUpdate;
const PostTagsField({
super.key,
this.initialTags,
required this.labelText,
required this.onUpdate,
});
@override
State<PostTagsField> createState() => _PostTagsFieldState();
}
class _PostTagsFieldState extends State<PostTagsField> {
static const List<String> kTagsDividers = [' ', ','];
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 sn = context.read<SnNetworkProvider>();
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<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(
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<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();
}