From 22ee8176767c868d18ffbabf67befcadfc573609 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sun, 7 Jul 2024 11:46:48 +0800
Subject: [PATCH] :sparkles: Support new feed API :sparkles: Able to add tag
 onto post

---
 lib/models/feed.dart                | 23 ++++++++
 lib/providers/content/post.dart     | 16 ++++-
 lib/screens/feed.dart               | 14 ++---
 lib/screens/posts/post_publish.dart | 16 +++++
 lib/translations.dart               |  2 +
 lib/widgets/posts/feed_list.dart    | 56 ++++++++++++++++++
 lib/widgets/posts/post_list.dart    | 74 ++++++++++++++++-------
 lib/widgets/posts/tags_field.dart   | 91 +++++++++++++++++++++++++++++
 pubspec.lock                        |  8 +++
 pubspec.yaml                        |  1 +
 10 files changed, 271 insertions(+), 30 deletions(-)
 create mode 100644 lib/models/feed.dart
 create mode 100644 lib/widgets/posts/feed_list.dart
 create mode 100644 lib/widgets/posts/tags_field.dart

diff --git a/lib/models/feed.dart b/lib/models/feed.dart
new file mode 100644
index 0000000..224d774
--- /dev/null
+++ b/lib/models/feed.dart
@@ -0,0 +1,23 @@
+class FeedRecord {
+  String type;
+  Map<String, dynamic> data;
+  DateTime createdAt;
+
+  FeedRecord({
+    required this.type,
+    required this.data,
+    required this.createdAt,
+  });
+
+  factory FeedRecord.fromJson(Map<String, dynamic> json) => FeedRecord(
+        type: json['type'],
+        data: json['data'],
+        createdAt: DateTime.parse(json['created_at']),
+      );
+
+  Map<String, dynamic> toJson() => {
+        'type': type,
+        'data': data,
+        'created_at': createdAt.toIso8601String(),
+      };
+}
diff --git a/lib/providers/content/post.dart b/lib/providers/content/post.dart
index 5ae3b7d..beb1f17 100644
--- a/lib/providers/content/post.dart
+++ b/lib/providers/content/post.dart
@@ -7,7 +7,7 @@ class PostProvider extends GetConnect {
     httpClient.baseUrl = ServiceFinder.services['interactive'];
   }
 
-  Future<Response> listPost(int page, {int? realm}) async {
+  Future<Response> listFeed(int page, {int? realm}) async {
     final queries = [
       'take=${10}',
       'offset=$page',
@@ -21,6 +21,20 @@ class PostProvider extends GetConnect {
     return resp;
   }
 
+  Future<Response> listPost(int page, {int? realm}) async {
+    final queries = [
+      'take=${10}',
+      'offset=$page',
+      if (realm != null) 'realmId=$realm',
+    ];
+    final resp = await get('/api/posts?${queries.join('&')}');
+    if (resp.statusCode != 200) {
+      throw Exception(resp.body);
+    }
+
+    return resp;
+  }
+
   Future<Response> listPostReplies(String alias, int page) async {
     final resp = await get('/api/posts/$alias/replies?take=${10}&offset=$page');
     if (resp.statusCode != 200) {
diff --git a/lib/screens/feed.dart b/lib/screens/feed.dart
index 04c7906..e557eda 100644
--- a/lib/screens/feed.dart
+++ b/lib/screens/feed.dart
@@ -1,8 +1,8 @@
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
 import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
+import 'package:solian/models/feed.dart';
 import 'package:solian/models/pagination.dart';
-import 'package:solian/models/post.dart';
 import 'package:solian/providers/auth.dart';
 import 'package:solian/providers/content/post.dart';
 import 'package:solian/router.dart';
@@ -10,7 +10,7 @@ import 'package:solian/screens/account/notification.dart';
 import 'package:solian/theme.dart';
 import 'package:solian/widgets/app_bar_title.dart';
 import 'package:solian/widgets/current_state_action.dart';
-import 'package:solian/widgets/posts/post_list.dart';
+import 'package:solian/widgets/posts/feed_list.dart';
 
 class FeedScreen extends StatefulWidget {
   const FeedScreen({super.key});
@@ -20,7 +20,7 @@ class FeedScreen extends StatefulWidget {
 }
 
 class _FeedScreenState extends State<FeedScreen> {
-  final PagingController<int, Post> _pagingController =
+  final PagingController<int, FeedRecord> _pagingController =
       PagingController(firstPageKey: 0);
 
   getPosts(int pageKey) async {
@@ -28,14 +28,14 @@ class _FeedScreenState extends State<FeedScreen> {
 
     Response resp;
     try {
-      resp = await provider.listPost(pageKey);
+      resp = await provider.listFeed(pageKey);
     } catch (e) {
       _pagingController.error = e;
       return;
     }
 
     final PaginationResult result = PaginationResult.fromJson(resp.body);
-    final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
+    final parsed = result.data?.map((e) => FeedRecord.fromJson(e)).toList();
     if (parsed != null && parsed.length >= 10) {
       _pagingController.appendPage(parsed, pageKey + parsed.length);
     } else if (parsed != null) {
@@ -78,7 +78,7 @@ class _FeedScreenState extends State<FeedScreen> {
                   ),
                 ],
               ),
-              PostListWidget(controller: _pagingController),
+              FeedListWidget(controller: _pagingController),
             ],
           ),
         ),
@@ -104,7 +104,7 @@ class FeedCreationButton extends StatelessWidget {
               icon: const Icon(Icons.add_circle),
               onPressed: () {
                 AppRouter.instance.pushNamed('postPublishing').then((val) {
-                  if (val == true && onCreated != null) {
+                  if (val != null && onCreated != null) {
                     onCreated!();
                   }
                 });
diff --git a/lib/screens/posts/post_publish.dart b/lib/screens/posts/post_publish.dart
index 55afdcc..6dc9c92 100644
--- a/lib/screens/posts/post_publish.dart
+++ b/lib/screens/posts/post_publish.dart
@@ -12,7 +12,9 @@ import 'package:solian/widgets/account/account_avatar.dart';
 import 'package:solian/widgets/app_bar_title.dart';
 import 'package:solian/widgets/attachments/attachment_publish.dart';
 import 'package:solian/widgets/posts/post_item.dart';
+import 'package:solian/widgets/posts/tags_field.dart';
 import 'package:solian/widgets/prev_page.dart';
+import 'package:textfield_tags/textfield_tags.dart';
 
 class PostPublishingArguments {
   final Post? edit;
@@ -43,6 +45,7 @@ class PostPublishingScreen extends StatefulWidget {
 
 class _PostPublishingScreenState extends State<PostPublishingScreen> {
   final _contentController = TextEditingController();
+  final _tagsController = StringTagController();
 
   bool _isBusy = false;
 
@@ -70,6 +73,8 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
 
     final payload = {
       'content': _contentController.value.text,
+      'tags': _tagsController.getTags?.map((x) => {'alias': x}).toList() ??
+          List.empty(),
       'attachments': _attachments,
       if (widget.edit != null) 'alias': widget.edit!.alias,
       if (widget.reply != null) 'reply_to': widget.reply!.id,
@@ -242,6 +247,10 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
                 right: 0,
                 child: Column(
                   children: [
+                    TagsField(
+                      tagsController: _tagsController,
+                      hintText: 'postTagsPlaceholder'.tr,
+                    ),
                     const Divider(thickness: 0.3, height: 0.3),
                     SizedBox(
                       height: 56,
@@ -266,4 +275,11 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
       ),
     );
   }
+
+  @override
+  void dispose() {
+    _contentController.dispose();
+    _tagsController.dispose();
+    super.dispose();
+  }
 }
diff --git a/lib/translations.dart b/lib/translations.dart
index 5d4fae3..6851a6a 100644
--- a/lib/translations.dart
+++ b/lib/translations.dart
@@ -88,6 +88,7 @@ class SolianMessages extends Translations {
           'postPublishing': 'Post a post',
           'postIdentityNotify': 'You will post this post as',
           'postContentPlaceholder': 'What\'s happened?!',
+          'postTagsPlaceholder': 'Tags',
           'postReaction': 'Reactions of the Post',
           'postActionList': 'Actions of Post',
           'postReplyAction': 'Make a reply',
@@ -325,6 +326,7 @@ class SolianMessages extends Translations {
           'postPublishing': '发表帖子',
           'postIdentityNotify': '你将会以本身份发表帖子',
           'postContentPlaceholder': '发生什么事了?!',
+          'postTagsPlaceholder': '标签',
           'postReaction': '帖子的反应',
           'postActionList': '帖子的操作',
           'postReplyAction': '发表一则回复',
diff --git a/lib/widgets/posts/feed_list.dart b/lib/widgets/posts/feed_list.dart
new file mode 100644
index 0000000..c537248
--- /dev/null
+++ b/lib/widgets/posts/feed_list.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
+import 'package:solian/models/feed.dart';
+import 'package:solian/models/post.dart';
+import 'package:solian/widgets/centered_container.dart';
+import 'package:solian/widgets/posts/post_list.dart';
+
+class FeedListWidget extends StatelessWidget {
+  final bool isShowEmbed;
+  final bool isClickable;
+  final bool isNestedClickable;
+  final PagingController<int, FeedRecord> controller;
+
+  const FeedListWidget({
+    super.key,
+    required this.controller,
+    this.isShowEmbed = true,
+    this.isClickable = true,
+    this.isNestedClickable = true,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return PagedSliverList<int, FeedRecord>.separated(
+      pagingController: controller,
+      builderDelegate: PagedChildBuilderDelegate<FeedRecord>(
+        itemBuilder: (context, item, index) {
+          return RepaintBoundary(
+            child: CenteredContainer(
+              child: Builder(
+                builder: (context) {
+                  switch (item.type) {
+                    case 'post':
+                      final data = Post.fromJson(item.data);
+                      return PostListEntryWidget(
+                        isShowEmbed: isShowEmbed,
+                        isNestedClickable: isNestedClickable,
+                        isClickable: isClickable,
+                        item: data,
+                        onUpdate: () {
+                          controller.refresh();
+                        },
+                      );
+                    default:
+                      return const SizedBox();
+                  }
+                },
+              ),
+            ),
+          );
+        },
+      ),
+      separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3),
+    );
+  }
+}
diff --git a/lib/widgets/posts/post_list.dart b/lib/widgets/posts/post_list.dart
index 6a0f7f2..5c9753e 100644
--- a/lib/widgets/posts/post_list.dart
+++ b/lib/widgets/posts/post_list.dart
@@ -29,28 +29,13 @@ class PostListWidget extends StatelessWidget {
         itemBuilder: (context, item, index) {
           return RepaintBoundary(
             child: CenteredContainer(
-              child: GestureDetector(
-                child: PostItem(
-                  key: Key('p${item.alias}'),
-                  item: item,
-                  isShowEmbed: isShowEmbed,
-                  isClickable: isNestedClickable,
-                ).paddingSymmetric(vertical: 8),
-                onTap: () {
-                  if (!isClickable) return;
-                  AppRouter.instance.pushNamed(
-                    'postDetail',
-                    pathParameters: {'alias': item.alias},
-                  );
-                },
-                onLongPress: () {
-                  showModalBottomSheet(
-                    useRootNavigator: true,
-                    context: context,
-                    builder: (context) => PostAction(item: item),
-                  ).then((value) {
-                    if (value != null) controller.refresh();
-                  });
+              child: PostListEntryWidget(
+                isShowEmbed: isShowEmbed,
+                isNestedClickable: isNestedClickable,
+                isClickable: isClickable,
+                item: item,
+                onUpdate: () {
+                  controller.refresh();
                 },
               ),
             ),
@@ -61,3 +46,48 @@ class PostListWidget extends StatelessWidget {
     );
   }
 }
+
+class PostListEntryWidget extends StatelessWidget {
+  final bool isShowEmbed;
+  final bool isNestedClickable;
+  final bool isClickable;
+  final Post item;
+  final Function onUpdate;
+
+  const PostListEntryWidget({
+    super.key,
+    required this.isShowEmbed,
+    required this.isNestedClickable,
+    required this.isClickable,
+    required this.item,
+    required this.onUpdate,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      child: PostItem(
+        key: Key('p${item.alias}'),
+        item: item,
+        isShowEmbed: isShowEmbed,
+        isClickable: isNestedClickable,
+      ).paddingSymmetric(vertical: 8),
+      onTap: () {
+        if (!isClickable) return;
+        AppRouter.instance.pushNamed(
+          'postDetail',
+          pathParameters: {'alias': item.alias},
+        );
+      },
+      onLongPress: () {
+        showModalBottomSheet(
+          useRootNavigator: true,
+          context: context,
+          builder: (context) => PostAction(item: item),
+        ).then((value) {
+          if (value != null) onUpdate();
+        });
+      },
+    );
+  }
+}
diff --git a/lib/widgets/posts/tags_field.dart b/lib/widgets/posts/tags_field.dart
new file mode 100644
index 0000000..9ca7b2c
--- /dev/null
+++ b/lib/widgets/posts/tags_field.dart
@@ -0,0 +1,91 @@
+import 'package:flutter/material.dart';
+import 'package:textfield_tags/textfield_tags.dart';
+
+class TagsField extends StatelessWidget {
+  final String hintText;
+
+  const TagsField({
+    super.key,
+    required this.hintText,
+    required StringTagController<String> tagsController,
+  }) : _tagsController = tagsController;
+
+  final StringTagController<String> _tagsController;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.symmetric(
+        horizontal: 16,
+        vertical: 8,
+      ),
+      child: TextFieldTags<String>(
+        letterCase: LetterCase.small,
+        textfieldTagsController: _tagsController,
+        textSeparators: const [' ', ','],
+        inputFieldBuilder: (context, inputFieldValues) {
+          return TextField(
+            controller: inputFieldValues.textEditingController,
+            focusNode: inputFieldValues.focusNode,
+            decoration: InputDecoration(
+              isDense: true,
+              hintText: hintText,
+              border: InputBorder.none,
+              prefixIconConstraints: BoxConstraints(
+                maxWidth: MediaQuery.of(context).size.width * 0.8,
+              ),
+              prefixIcon: inputFieldValues.tags.isNotEmpty
+                  ? SingleChildScrollView(
+                      controller: inputFieldValues.tagScrollController,
+                      scrollDirection: Axis.horizontal,
+                      child: Row(
+                          children: inputFieldValues.tags.map((String tag) {
+                        return Container(
+                          decoration: BoxDecoration(
+                            borderRadius: const BorderRadius.all(
+                              Radius.circular(20.0),
+                            ),
+                            color: Theme.of(context).colorScheme.primary,
+                          ),
+                          margin: const EdgeInsets.only(right: 10.0),
+                          padding: const EdgeInsets.symmetric(
+                              horizontal: 10.0, vertical: 4.0),
+                          child: Row(
+                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                            children: [
+                              InkWell(
+                                child: Text(
+                                  '#$tag',
+                                  style: const TextStyle(color: Colors.white),
+                                ),
+                                onTap: () {
+                                  //print("$tag selected");
+                                },
+                              ),
+                              const SizedBox(width: 4.0),
+                              InkWell(
+                                child: const Icon(
+                                  Icons.cancel,
+                                  size: 14.0,
+                                  color: Color.fromARGB(255, 233, 233, 233),
+                                ),
+                                onTap: () {
+                                  inputFieldValues.onTagRemoved(tag);
+                                },
+                              )
+                            ],
+                          ),
+                        );
+                      }).toList()),
+                    )
+                  : null,
+            ),
+            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
+            onChanged: inputFieldValues.onTagChanged,
+            onSubmitted: inputFieldValues.onTagSubmitted,
+          );
+        },
+      ),
+    );
+  }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 9b52e54..83f4297 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1557,6 +1557,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.7.0"
+  textfield_tags:
+    dependency: "direct main"
+    description:
+      name: textfield_tags
+      sha256: d1f2204114157a1296bb97c20d7f8c8c7fd036212812afb2e19de7bb34acc55b
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.1"
   timeago:
     dependency: "direct main"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 7299b6c..cb53164 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -52,6 +52,7 @@ dependencies:
   media_kit: ^1.1.10+1
   media_kit_video: ^1.2.4
   media_kit_libs_video: ^1.0.4
+  textfield_tags: ^3.0.1
 
 dev_dependencies:
   flutter_test: