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 data; + DateTime createdAt; + + FeedRecord({ + required this.type, + required this.data, + required this.createdAt, + }); + + factory FeedRecord.fromJson(Map json) => FeedRecord( + type: json['type'], + data: json['data'], + createdAt: DateTime.parse(json['created_at']), + ); + + Map 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 listPost(int page, {int? realm}) async { + Future listFeed(int page, {int? realm}) async { final queries = [ 'take=${10}', 'offset=$page', @@ -21,6 +21,20 @@ class PostProvider extends GetConnect { return resp; } + Future 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 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 { - final PagingController _pagingController = + final PagingController _pagingController = PagingController(firstPageKey: 0); getPosts(int pageKey) async { @@ -28,14 +28,14 @@ class _FeedScreenState extends State { 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 { ), ], ), - 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 { final _contentController = TextEditingController(); + final _tagsController = StringTagController(); bool _isBusy = false; @@ -70,6 +73,8 @@ class _PostPublishingScreenState extends State { 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 { 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 { ), ); } + + @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 controller; + + const FeedListWidget({ + super.key, + required this.controller, + this.isShowEmbed = true, + this.isClickable = true, + this.isNestedClickable = true, + }); + + @override + Widget build(BuildContext context) { + return PagedSliverList.separated( + pagingController: controller, + builderDelegate: PagedChildBuilderDelegate( + 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 tagsController, + }) : _tagsController = tagsController; + + final StringTagController _tagsController; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: TextFieldTags( + 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: