diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 7b712fb..f29bc8b 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -86,6 +86,7 @@ "fieldPostTitle": "Title", "fieldPostDescription": "Description", "postPublish": "Publish", + "postPosted": "Post has been posted.", "postPublishedAt": "Published At", "postPublishedUntil": "Published Until", "postEditingNotice": "You're about to editing a post that posted {}.", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 74fe9b9..4fd5621 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -92,6 +92,7 @@ "postReplyingNotice": "你正在回复由 {} 发布的帖子。", "postRepostingNotice": "你正在转发由 {} 发布的帖子。", "postReact": "反应", + "postPosted": "帖子已经发表。", "postComments": { "zero": "评论", "one": "{} 条评论", diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index b881eb1..b2c5798 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -234,7 +234,7 @@ class PostWriteController extends ChangeNotifier { } } - void post(BuildContext context) async { + Future post(BuildContext context) async { if (isBusy || publisher == null) return; final sn = context.read(); @@ -294,8 +294,9 @@ class PostWriteController extends ChangeNotifier { data: { 'publisher': publisher!.id, 'content': contentController.text, - 'title': titleController.text, - 'description': descriptionController.text, + if (titleController.text.isNotEmpty) 'title': titleController.text, + if (descriptionController.text.isNotEmpty) + 'description': descriptionController.text, 'attachments': attachments .where((e) => e.attachment != null) .map((e) => e.attachment!.rid) @@ -322,8 +323,6 @@ class PostWriteController extends ChangeNotifier { method: editingPost != null ? 'PUT' : 'POST', ), ); - if (!context.mounted) return; - Navigator.pop(context, true); } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); @@ -368,6 +367,20 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void reset() { + publishedAt = null; + publishedUntil = null; + titleController.clear(); + descriptionController.clear(); + contentController.clear(); + attachments.clear(); + editingPost = null; + replyingPost = null; + repostingPost = null; + mode = kTitleMap.keys.first; + notifyListeners(); + } + @override void dispose() { contentController.dispose(); diff --git a/lib/screens/post/post_detail.dart b/lib/screens/post/post_detail.dart index f78097a..6bcdb73 100644 --- a/lib/screens/post/post_detail.dart +++ b/lib/screens/post/post_detail.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_attachment.dart'; @@ -14,6 +15,7 @@ import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_item.dart'; +import 'package:surface/widgets/post/post_mini_editor.dart'; class PostDetailScreen extends StatefulWidget { final String slug; @@ -68,8 +70,12 @@ class _PostDetailScreenState extends State { _fetchPost(); } + final GlobalKey _childListKey = GlobalKey(); + @override Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + return AppScaffold( appBar: AppBar( leading: BackButton( @@ -98,17 +104,47 @@ class _PostDetailScreenState extends State { ), if (_data != null) SliverToBoxAdapter( - child: PostItem(data: _data!), + child: PostItem(data: _data!, showComments: false), ), const SliverToBoxAdapter(child: Divider(height: 1)), if (_data != null) SliverToBoxAdapter( - child: Text('postCommentsDetailed') - .plural(_data!.metric.replyCount) - .textStyle(Theme.of(context).textTheme.titleLarge!) - .padding(horizontal: 16, top: 12, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.comment, size: 24), + const Gap(16), + Text('postCommentsDetailed') + .plural(_data!.metric.replyCount) + .textStyle(Theme.of(context).textTheme.titleLarge!), + ], + ).padding(horizontal: 20, vertical: 12), + ), + if (_data != null) + SliverToBoxAdapter( + child: Container( + height: 240, + decoration: BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide( + color: Theme.of(context).dividerColor, + width: 1 / devicePixelRatio, + ), + ), + ), + child: PostMiniEditor( + postReplyId: _data!.id, + onPost: () { + _childListKey.currentState!.refresh(); + }, + ), + ), + ), + if (_data != null) + PostCommentSliverList( + key: _childListKey, + parentPostId: _data!.id, ), - if (_data != null) PostCommentSliverList(parentPostId: _data!.id), SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), ], ), diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 314e815..0450b36 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -328,7 +328,7 @@ class _PostEditorScreenState extends State { ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - ) + ), ] .expandIndexed( (idx, ele) => [ @@ -390,7 +390,12 @@ class _PostEditorScreenState extends State { onPressed: (_writeController.isBusy || _writeController.publisher == null) ? null - : () => _writeController.post(context), + : () { + _writeController.post(context).then((_) { + if (!context.mounted) return; + Navigator.pop(context, true); + }); + }, icon: const Icon(Symbols.send), label: Text('postPublish').tr(), ), diff --git a/lib/widgets/post/post_comment_list.dart b/lib/widgets/post/post_comment_list.dart index 405851b..9eff88b 100644 --- a/lib/widgets/post/post_comment_list.dart +++ b/lib/widgets/post/post_comment_list.dart @@ -9,6 +9,7 @@ import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/post/post_item.dart'; +import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class PostCommentSliverList extends StatefulWidget { @@ -16,10 +17,10 @@ class PostCommentSliverList extends StatefulWidget { const PostCommentSliverList({super.key, required this.parentPostId}); @override - State createState() => _PostCommentSliverListState(); + State createState() => PostCommentSliverListState(); } -class _PostCommentSliverListState extends State { +class PostCommentSliverListState extends State { bool _isBusy = true; final List _posts = List.empty(growable: true); @@ -67,6 +68,11 @@ class _PostCommentSliverListState extends State { if (mounted) setState(() => _isBusy = false); } + Future refresh() async { + _posts.clear(); + _fetchPosts(); + } + @override void initState() { super.initState(); @@ -97,7 +103,7 @@ class _PostCommentSliverListState extends State { } } -class PostCommentListPopup extends StatelessWidget { +class PostCommentListPopup extends StatefulWidget { final int postId; final int commentCount; const PostCommentListPopup({ @@ -106,8 +112,17 @@ class PostCommentListPopup extends StatelessWidget { this.commentCount = 0, }); + @override + State createState() => _PostCommentListPopupState(); +} + +class _PostCommentListPopupState extends State { + final GlobalKey _childListKey = GlobalKey(); + @override Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -117,14 +132,36 @@ class PostCommentListPopup extends StatelessWidget { const Icon(Symbols.comment, size: 24), const Gap(16), Text('postCommentsDetailed') - .plural(commentCount) + .plural(widget.commentCount) .textStyle(Theme.of(context).textTheme.titleLarge!), ], ).padding(horizontal: 20, top: 16, bottom: 12), Expanded( child: CustomScrollView( slivers: [ - PostCommentSliverList(parentPostId: postId), + SliverToBoxAdapter( + child: Container( + height: 240, + decoration: BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide( + color: Theme.of(context).dividerColor, + width: 1 / devicePixelRatio, + ), + ), + ), + child: PostMiniEditor( + postReplyId: widget.postId, + onPost: () { + _childListKey.currentState!.refresh(); + }, + ), + ), + ), + PostCommentSliverList( + key: _childListKey, + parentPostId: widget.postId, + ), ], ), ), diff --git a/lib/widgets/post/post_mini_editor.dart b/lib/widgets/post/post_mini_editor.dart index 3403fcb..24a267d 100644 --- a/lib/widgets/post/post_mini_editor.dart +++ b/lib/widgets/post/post_mini_editor.dart @@ -1,10 +1,236 @@ -import 'package:flutter/widgets.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; -class PostMiniEditor extends StatelessWidget { - const PostMiniEditor({super.key}); +class PostMiniEditor extends StatefulWidget { + final int? postReplyId; + final Function? onPost; + const PostMiniEditor({super.key, this.postReplyId, this.onPost}); + + @override + State createState() => _PostMiniEditorState(); +} + +class _PostMiniEditorState extends State { + final PostWriteController _writeController = PostWriteController(); + + bool _isFetching = false; + bool get _isLoading => _isFetching || _writeController.isLoading; + + List? _publishers; + + Future _fetchPublishers() async { + setState(() => _isFetching = true); + + try { + final sn = context.read(); + final resp = await sn.client.get('/cgi/co/publishers'); + _publishers = List.from( + resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], + ); + _writeController.setPublisher(_publishers?.firstOrNull); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isFetching = false); + } + } + + @override + void initState() { + super.initState(); + _fetchPublishers(); + _writeController.fetchRelatedPost( + context, + replying: widget.postReplyId, + ); + } + + @override + void dispose() { + _writeController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return const Placeholder(); + return ListenableBuilder( + listenable: _writeController, + builder: (context, _) { + return Column( + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: Text( + 'fieldPostPublisher', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ).tr(), + items: >[ + ...(_publishers?.map( + (item) => DropdownMenuItem( + enabled: _writeController.editingPost == null, + value: item, + child: Row( + children: [ + AccountImage(content: item.avatar, radius: 16), + const Gap(8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(item.nick).textStyle( + Theme.of(context) + .textTheme + .bodyMedium!), + Text('@${item.name}') + .textStyle(Theme.of(context) + .textTheme + .bodySmall!) + .fontSize(12), + ], + ), + ), + ], + ), + ), + ) ?? + []), + DropdownMenuItem( + value: null, + child: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Colors.transparent, + foregroundColor: + Theme.of(context).colorScheme.onSurface, + child: const Icon(Symbols.add), + ), + const Gap(8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('publishersNew').tr().textStyle( + Theme.of(context).textTheme.bodyMedium!), + ], + ), + ), + ], + ), + ), + ], + value: _writeController.publisher, + onChanged: (SnPublisher? value) { + if (value == null) { + GoRouter.of(context) + .pushNamed('accountPublisherNew') + .then((value) { + if (value == true) { + _publishers = null; + _fetchPublishers(); + } + }); + } else { + _writeController.setPublisher(value); + } + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.only(right: 16), + height: 48, + ), + menuItemStyleData: const MenuItemStyleData( + height: 48, + ), + ), + ), + const Divider(height: 1), + const Gap(8), + Expanded( + child: TextField( + controller: _writeController.contentController, + maxLines: null, + decoration: InputDecoration( + hintText: 'fieldPostContent'.tr(), + hintStyle: TextStyle(fontSize: 14), + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + border: InputBorder.none, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + const Gap(8), + LoadingIndicator(isActive: _isLoading), + if (_writeController.isBusy && _writeController.progress != null) + TweenAnimationBuilder( + tween: Tween(begin: 0, end: _writeController.progress), + duration: Duration(milliseconds: 300), + builder: (context, value, _) => + LinearProgressIndicator(value: value, minHeight: 2), + ) + else if (_writeController.isBusy) + const LinearProgressIndicator(value: null, minHeight: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Symbols.launch, + color: Theme.of(context).colorScheme.secondary, + ), + onPressed: () { + GoRouter.of(context).pushNamed( + 'postEditor', + pathParameters: {'mode': 'stories'}, + queryParameters: { + if (widget.postReplyId != null) + 'replying': widget.postReplyId.toString(), + }, + ); + }, + ), + TextButton.icon( + onPressed: (_writeController.isBusy || + _writeController.publisher == null) + ? null + : () { + _writeController.post(context).then((_) { + if (!context.mounted) return; + if (widget.onPost != null) widget.onPost!(); + context.showSnackbar('postPosted'.tr()); + _writeController.reset(); + }); + }, + icon: const Icon(Symbols.send), + label: Text('postPublish').tr(), + ), + ], + ).padding(left: 12, right: 16, bottom: 4), + ], + ); + }); } }