✨ Mini editor
This commit is contained in:
		| @@ -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 {}.", | ||||
|   | ||||
| @@ -92,6 +92,7 @@ | ||||
|   "postReplyingNotice": "你正在回复由 {} 发布的帖子。", | ||||
|   "postRepostingNotice": "你正在转发由 {} 发布的帖子。", | ||||
|   "postReact": "反应", | ||||
|   "postPosted": "帖子已经发表。", | ||||
|   "postComments": { | ||||
|     "zero": "评论", | ||||
|     "one": "{} 条评论", | ||||
|   | ||||
| @@ -234,7 +234,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void post(BuildContext context) async { | ||||
|   Future<void> post(BuildContext context) async { | ||||
|     if (isBusy || publisher == null) return; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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<PostDetailScreen> { | ||||
|     _fetchPost(); | ||||
|   } | ||||
|  | ||||
|   final GlobalKey<PostCommentSliverListState> _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<PostDetailScreen> { | ||||
|           ), | ||||
|           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)), | ||||
|         ], | ||||
|       ), | ||||
|   | ||||
| @@ -328,7 +328,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                         ), | ||||
|                         onTapOutside: (_) => | ||||
|                             FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                       ) | ||||
|                       ), | ||||
|                     ] | ||||
|                         .expandIndexed( | ||||
|                           (idx, ele) => [ | ||||
| @@ -390,7 +390,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                           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(), | ||||
|                         ), | ||||
|   | ||||
| @@ -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<PostCommentSliverList> createState() => _PostCommentSliverListState(); | ||||
|   State<PostCommentSliverList> createState() => PostCommentSliverListState(); | ||||
| } | ||||
|  | ||||
| class _PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
| class PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
|   bool _isBusy = true; | ||||
|  | ||||
|   final List<SnPost> _posts = List.empty(growable: true); | ||||
| @@ -67,6 +68,11 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
|     if (mounted) setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   Future<void> refresh() async { | ||||
|     _posts.clear(); | ||||
|     _fetchPosts(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
| @@ -97,7 +103,7 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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<PostCommentListPopup> createState() => _PostCommentListPopupState(); | ||||
| } | ||||
|  | ||||
| class _PostCommentListPopupState extends State<PostCommentListPopup> { | ||||
|   final GlobalKey<PostCommentSliverListState> _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, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|   | ||||
| @@ -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<PostMiniEditor> createState() => _PostMiniEditorState(); | ||||
| } | ||||
|  | ||||
| class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|   final PostWriteController _writeController = PostWriteController(); | ||||
|  | ||||
|   bool _isFetching = false; | ||||
|   bool get _isLoading => _isFetching || _writeController.isLoading; | ||||
|  | ||||
|   List<SnPublisher>? _publishers; | ||||
|  | ||||
|   Future<void> _fetchPublishers() async { | ||||
|     setState(() => _isFetching = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/publishers'); | ||||
|       _publishers = List<SnPublisher>.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<SnPublisher>( | ||||
|                   isExpanded: true, | ||||
|                   hint: Text( | ||||
|                     'fieldPostPublisher', | ||||
|                     style: TextStyle( | ||||
|                       fontSize: 14, | ||||
|                       color: Theme.of(context).hintColor, | ||||
|                     ), | ||||
|                   ).tr(), | ||||
|                   items: <DropdownMenuItem<SnPublisher>>[ | ||||
|                     ...(_publishers?.map( | ||||
|                           (item) => DropdownMenuItem<SnPublisher>( | ||||
|                             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<SnPublisher>( | ||||
|                       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<double>( | ||||
|                   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), | ||||
|             ], | ||||
|           ); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user