✨ Post tags
This commit is contained in:
		@@ -103,6 +103,7 @@
 | 
			
		||||
  "fieldPostContent": "What happened?!",
 | 
			
		||||
  "fieldPostTitle": "Title",
 | 
			
		||||
  "fieldPostDescription": "Description",
 | 
			
		||||
  "fieldPostTags": "Tags",
 | 
			
		||||
  "postPublish": "Publish",
 | 
			
		||||
  "postPosted": "Post has been posted.",
 | 
			
		||||
  "postPublishedAt": "Published At",
 | 
			
		||||
 
 | 
			
		||||
@@ -103,6 +103,7 @@
 | 
			
		||||
  "fieldPostContent": "发生什么事了?!",
 | 
			
		||||
  "fieldPostTitle": "标题",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "标签",
 | 
			
		||||
  "postPublish": "发布",
 | 
			
		||||
  "postPublishedAt": "发布于",
 | 
			
		||||
  "postPublishedUntil": "取消发布于",
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -172,6 +172,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  SnPublisher? publisher;
 | 
			
		||||
  SnPost? editingPost, repostingPost, replyingPost;
 | 
			
		||||
 | 
			
		||||
  List<String> tags = List.empty();
 | 
			
		||||
  List<PostWriteMedia> 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<String> value) {
 | 
			
		||||
    tags = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setIsBusy(bool value) {
 | 
			
		||||
    isBusy = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -112,7 +112,7 @@ class SnPostContentProvider {
 | 
			
		||||
  Future<SnPost> 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;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -141,7 +141,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
                const TextSpan(text: '\n'),
 | 
			
		||||
                TextSpan(
 | 
			
		||||
                  text: PostWriteController.kTitleMap[widget.mode]!,
 | 
			
		||||
                  text: PostWriteController.kTitleMap[widget.mode]!.tr(),
 | 
			
		||||
                  style: Theme.of(context)
 | 
			
		||||
                      .textTheme
 | 
			
		||||
                      .bodySmall!
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ class SnPost with _$SnPost {
 | 
			
		||||
    required String language,
 | 
			
		||||
    required String? alias,
 | 
			
		||||
    required String? aliasPrefix,
 | 
			
		||||
    @Default([]) List<dynamic> tags,
 | 
			
		||||
    @Default([]) List<SnPostTag> tags,
 | 
			
		||||
    @Default([]) List<dynamic> categories,
 | 
			
		||||
    required List<SnPost>? 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<String, Object?> json) =>
 | 
			
		||||
      _$SnPostTagFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnPostPreload with _$SnPostPreload {
 | 
			
		||||
  const factory SnPostPreload({
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ mixin _$SnPost {
 | 
			
		||||
  String get language => throw _privateConstructorUsedError;
 | 
			
		||||
  String? get alias => 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<SnPost>? get replies => throw _privateConstructorUsedError;
 | 
			
		||||
  int? get replyId => throw _privateConstructorUsedError;
 | 
			
		||||
@@ -76,7 +76,7 @@ abstract class $SnPostCopyWith<$Res> {
 | 
			
		||||
      String language,
 | 
			
		||||
      String? alias,
 | 
			
		||||
      String? aliasPrefix,
 | 
			
		||||
      List<dynamic> tags,
 | 
			
		||||
      List<SnPostTag> tags,
 | 
			
		||||
      List<dynamic> categories,
 | 
			
		||||
      List<SnPost>? 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<dynamic>,
 | 
			
		||||
              as List<SnPostTag>,
 | 
			
		||||
      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<dynamic> tags,
 | 
			
		||||
      List<SnPostTag> tags,
 | 
			
		||||
      List<dynamic> categories,
 | 
			
		||||
      List<SnPost>? replies,
 | 
			
		||||
      int? replyId,
 | 
			
		||||
@@ -481,7 +481,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
 | 
			
		||||
      tags: null == tags
 | 
			
		||||
          ? _value._tags
 | 
			
		||||
          : tags // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<dynamic>,
 | 
			
		||||
              as List<SnPostTag>,
 | 
			
		||||
      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<dynamic> tags = const [],
 | 
			
		||||
      final List<SnPostTag> tags = const [],
 | 
			
		||||
      final List<dynamic> categories = const [],
 | 
			
		||||
      required final List<SnPost>? replies,
 | 
			
		||||
      required this.replyId,
 | 
			
		||||
@@ -640,10 +640,10 @@ class _$SnPostImpl extends _SnPost {
 | 
			
		||||
  final String? alias;
 | 
			
		||||
  @override
 | 
			
		||||
  final String? aliasPrefix;
 | 
			
		||||
  final List<dynamic> _tags;
 | 
			
		||||
  final List<SnPostTag> _tags;
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey()
 | 
			
		||||
  List<dynamic> get tags {
 | 
			
		||||
  List<SnPostTag> 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<dynamic> tags,
 | 
			
		||||
      final List<SnPostTag> tags,
 | 
			
		||||
      final List<dynamic> categories,
 | 
			
		||||
      required final List<SnPost>? replies,
 | 
			
		||||
      required final int? replyId,
 | 
			
		||||
@@ -897,7 +897,7 @@ abstract class _SnPost extends SnPost {
 | 
			
		||||
  @override
 | 
			
		||||
  String? get aliasPrefix;
 | 
			
		||||
  @override
 | 
			
		||||
  List<dynamic> get tags;
 | 
			
		||||
  List<SnPostTag> get tags;
 | 
			
		||||
  @override
 | 
			
		||||
  List<dynamic> get categories;
 | 
			
		||||
  @override
 | 
			
		||||
@@ -949,6 +949,310 @@ abstract class _SnPost extends SnPost {
 | 
			
		||||
      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) {
 | 
			
		||||
  return _SnPostPreload.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
 | 
			
		||||
      language: json['language'] as String,
 | 
			
		||||
      alias: json['alias'] 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 [],
 | 
			
		||||
      replies: (json['replies'] as List<dynamic>?)
 | 
			
		||||
          ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
@@ -76,7 +79,7 @@ Map<String, dynamic> _$$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<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
 | 
			
		||||
      '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(
 | 
			
		||||
      thumbnail: json['thumbnail'] == null
 | 
			
		||||
 
 | 
			
		||||
@@ -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});
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										204
									
								
								lib/widgets/post/post_tags_field.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								lib/widgets/post/post_tags_field.dart
									
									
									
									
									
										Normal 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();
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user