diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 0b0b2ba..a761ffd 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -837,5 +837,6 @@ "fieldContactContent": "Contact method", "accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.", "accountContactMethodsDelete": "Delete Contact Method", - "accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible." + "accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.", + "postCommentAdd": "Write a comment" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index f30c2a8..ea639c2 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -837,5 +837,6 @@ "fieldContactContent": "联系方式", "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。", "accountContactMethodsDelete": "删除联系方式", - "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。" + "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。", + "postCommentAdd": "撰写一条评论" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index b0d2772..d47a093 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -837,5 +837,6 @@ "fieldContactContent": "聯繫方式", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsDelete": "刪除聯繫方式", - "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。" + "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", + "postCommentAdd": "撰寫一條評論" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index ef8a2b3..3b66708 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -837,5 +837,6 @@ "fieldContactContent": "聯繫方式", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsDelete": "刪除聯繫方式", - "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。" + "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。", + "postCommentAdd": "撰寫一條評論" } diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index e9f66de..1c3ea2a 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -551,12 +551,18 @@ class _PostListWidgetState extends State<_PostListWidget> { maxWidth: 640, ); case 'reader.news': - return NewsFeedEntry(data: ele); + return Container( + constraints: BoxConstraints(maxWidth: 640), + child: NewsFeedEntry(data: ele), + ); default: - return FeedUnknownEntry(data: ele); + return Container( + constraints: BoxConstraints(maxWidth: 640), + child: FeedUnknownEntry(data: ele), + ); } }, - separatorBuilder: (_, __) => const Gap(8), + separatorBuilder: (_, __) => const Divider().padding(vertical: 2), ), ), ); diff --git a/lib/widgets/post/post_comment_list.dart b/lib/widgets/post/post_comment_list.dart index cb883ea..ef85a64 100644 --- a/lib/widgets/post/post_comment_list.dart +++ b/lib/widgets/post/post_comment_list.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/post.dart'; +import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/userinfo.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/dialog.dart'; @@ -14,14 +15,13 @@ 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'; -import '../../providers/sn_network.dart'; - class PostCommentQuickAction extends StatelessWidget { final double? maxWidth; final SnPost parentPost; final Function? onPosted; - const PostCommentQuickAction({super.key, this.maxWidth, required this.parentPost, this.onPosted}); + const PostCommentQuickAction( + {super.key, this.maxWidth, required this.parentPost, this.onPosted}); @override Widget build(BuildContext context) { @@ -30,7 +30,9 @@ class PostCommentQuickAction extends StatelessWidget { return Container( height: 240, constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), - margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.symmetric(vertical: 8) : EdgeInsets.zero, + margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE) + ? const EdgeInsets.symmetric(vertical: 8) + : EdgeInsets.zero, decoration: BoxDecoration( borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const BorderRadius.all(Radius.circular(8)) @@ -99,7 +101,8 @@ class PostCommentSliverListState extends State { Future _selectAnswer(SnPost answer) async { try { final sn = context.read(); - await sn.client.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: { + await sn.client + .put('/cgi/co/questions/${widget.parentPost.id}/answer', data: { 'publisher': answer.publisherId, 'answer_id': answer.id, }); @@ -135,7 +138,9 @@ class PostCommentSliverListState extends State { child: PostItem( data: _posts[idx], maxWidth: widget.maxWidth, - onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null, + onSelectAnswer: widget.parentPost.type == 'question' + ? () => _selectAnswer(_posts[idx]) + : null, onChanged: (data) { setState(() => _posts[idx] = data); }, @@ -153,7 +158,8 @@ class PostCommentSliverListState extends State { }, ); }, - separatorBuilder: (context, index) => const Divider(height: 1), + separatorBuilder: (context, index) => + const Divider().padding(vertical: 2), ); } } @@ -188,7 +194,9 @@ class _PostCommentListPopupState extends State { children: [ const Icon(Symbols.comment, size: 24), const Gap(16), - Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!), + Text('postCommentsDetailed') + .plural(widget.commentCount) + .textStyle(Theme.of(context).textTheme.titleLarge!), ], ).padding(horizontal: 20, top: 16, bottom: 12), Expanded( diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 8a93460..afc7e41 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -72,34 +72,37 @@ class OpenablePostItem extends StatelessWidget { Widget build(BuildContext context) { final cfg = context.read(); - return Center( - child: OpenContainer( - closedBuilder: (_, __) => Container( - constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), - child: PostItem( - data: data, - maxWidth: maxWidth, - showComments: showComments, - showFullPost: showFullPost, - onChanged: onChanged, - onDeleted: onDeleted, - onSelectAnswer: onSelectAnswer, + return Container( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: Center( + child: OpenContainer( + closedBuilder: (_, __) => Container( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: PostItem( + data: data, + maxWidth: maxWidth, + showComments: showComments, + showFullPost: showFullPost, + onChanged: onChanged, + onDeleted: onDeleted, + onSelectAnswer: onSelectAnswer, + ), + ), + openBuilder: (_, close) => PostDetailScreen( + slug: data.id.toString(), + preload: data, + onBack: close, + ), + openColor: Colors.transparent, + openElevation: 0, + transitionType: ContainerTransitionType.fade, + closedElevation: 0, + closedColor: Theme.of(context).colorScheme.surface.withOpacity( + cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, + ), + closedShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), ), - ), - openBuilder: (_, close) => PostDetailScreen( - slug: data.id.toString(), - preload: data, - onBack: close, - ), - openColor: Colors.transparent, - openElevation: 0, - transitionType: ContainerTransitionType.fade, - closedColor: - Theme.of(context).colorScheme.surfaceContainerLow.withOpacity( - cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, - ), - closedShape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), ), ), ); @@ -202,61 +205,6 @@ class PostItem extends StatelessWidget { final ua = context.read(); final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id; - // Video full view - if (showFullPost && - data.type == 'video' && - ResponsiveBreakpoints.of(context).largerThan(TABLET)) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _PostContentHeader( - data: data, - isAuthor: isAuthor, - isRelativeDate: !showFullPost, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onSelectAnswer: onSelectAnswer, - onDeleted: () { - onDeleted?.call(); - }, - ).padding(bottom: 8), - if (data.preload?.video != null) - _PostVideoPlayer(data: data).padding(bottom: 8), - _PostHeadline(data: data).padding(horizontal: 4, bottom: 8), - _PostFeaturedComment(data: data), - _PostBottomAction( - data: data, - showComments: true, - showReactions: showReactions, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onChanged: _onChanged, - ), - ], - ), - ), - const Gap(4), - SizedBox( - width: 340, - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - PostCommentSliverList( - parentPost: data, - ), - ], - ), - ), - ], - ); - } - - // Article headline preview if (!showFullPost && data.type == 'article') { return Container( constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), @@ -265,14 +213,7 @@ class PostItem extends StatelessWidget { children: [ _PostContentHeader( data: data, - isAuthor: isAuthor, isRelativeDate: !showFullPost, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onSelectAnswer: onSelectAnswer, - onDeleted: () { - onDeleted?.call(); - }, ).padding(horizontal: 12, top: 8, bottom: 8), if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8), @@ -325,14 +266,6 @@ class PostItem extends StatelessWidget { .padding(horizontal: 24, bottom: 8), _PostFeaturedComment(data: data, maxWidth: maxWidth) .padding(horizontal: 12), - _PostBottomAction( - data: data, - showComments: showComments, - showReactions: showReactions, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onChanged: _onChanged, - ).padding(left: 8, right: 14), ], ), ).center(); @@ -345,6 +278,12 @@ class PostItem extends StatelessWidget { final cfg = context.read(); + var attachmentSize = math.min( + MediaQuery.of(context).size.width, maxWidth ?? double.infinity); + if ((data.preload?.attachments?.length ?? 0) > 1) { + attachmentSize -= 80; + } + return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -353,54 +292,85 @@ class PostItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _PostContentHeader( - isAuthor: isAuthor, - isRelativeDate: !showFullPost, - data: data, - showMenu: showMenu, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onSelectAnswer: onSelectAnswer, - onDeleted: () { - onDeleted?.call(); - }, - ).padding(horizontal: 12, vertical: 8), - if (data.preload?.video != null) - _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8), - if (data.type == 'question') - _PostQuestionHint(data: data) - .padding(horizontal: 16, bottom: 8), - if (data.body['title'] != null || - data.body['description'] != null) - _PostHeadline( - data: data, - isEnlarge: data.type == 'article' && showFullPost, - ).padding(horizontal: 16, bottom: 8), - if (data.body['content']?.isNotEmpty ?? false) - _PostContentBody( - data: data, - isSelectable: showFullPost, - isEnlarge: data.type == 'article' && showFullPost, - ).padding(horizontal: 16, bottom: 6), - if (data.repostTo != null) - _PostQuoteContent(child: data.repostTo!).padding( - horizontal: 12, - bottom: - data.preload?.attachments?.isNotEmpty ?? false ? 12 : 0, - ), - if (data.visibility > 0) - _PostVisibilityHint(data: data).padding( - horizontal: 16, - vertical: 4, - ), - if (data.body['content_truncated'] == true) - _PostTruncatedHint(data: data).padding( - horizontal: 16, - vertical: 4, - ), - if (data.tags.isNotEmpty) - _PostTagsList(data: data) - .padding(horizontal: 16, top: 4, bottom: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PostAvatar( + data: data, + isCompact: false, + ), + const Gap(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _PostContentHeader( + isRelativeDate: !showFullPost, + isCompact: true, + data: data, + ), + ), + _PostActionPopup( + data: data, + isAuthor: isAuthor, + onShare: () => _doShare(context), + onShareImage: () => _doShareViaPicture(context), + onSelectAnswer: onSelectAnswer, + onDeleted: () { + onDeleted?.call(); + }, + ), + ], + ), + const Gap(8), + if (data.preload?.video != null) + _PostVideoPlayer(data: data).padding(bottom: 8), + if (data.type == 'question') + _PostQuestionHint(data: data).padding(bottom: 8), + if (data.body['title'] != null || + data.body['description'] != null) + _PostHeadline( + data: data, + isEnlarge: data.type == 'article' && showFullPost, + ).padding(bottom: 8), + if (data.body['content']?.isNotEmpty ?? false) + _PostContentBody( + data: data, + isSelectable: showFullPost, + isEnlarge: data.type == 'article' && showFullPost, + ).padding(bottom: 6), + if (data.repostTo != null) + _PostQuoteContent(child: data.repostTo!).padding( + bottom: + data.preload?.attachments?.isNotEmpty ?? false + ? 12 + : 0, + ), + if (data.visibility > 0) + _PostVisibilityHint(data: data).padding( + vertical: 4, + ), + if (data.body['content_truncated'] == true) + _PostTruncatedHint(data: data).padding( + vertical: 4, + ), + if (data.tags.isNotEmpty) + _PostTagsList(data: data).padding(top: 4, bottom: 6), + Row( + children: [ + Icon(Symbols.play_circle, size: 20), + const Gap(4), + Text('postViews').plural(data.totalViews), + ], + ).opacity(0.75).padding(vertical: 4), + ], + ), + ) + ], + ).padding(horizontal: 12, top: 8), ], ), ), @@ -409,9 +379,10 @@ class PostItem extends StatelessWidget { data: displayableAttachments!, bordered: true, maxHeight: showFullPost ? null : 480, - maxWidth: MediaQuery.of(context).size.width - 20, + minWidth: attachmentSize, + maxWidth: attachmentSize, fit: showFullPost ? BoxFit.cover : BoxFit.contain, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.only(left: 60, right: 12), ), if (data.preload?.poll != null) PostPoll(poll: data.preload!.poll!) @@ -420,22 +391,15 @@ class PostItem extends StatelessWidget { (cfg.prefs.getBool(kAppExpandPostLink) ?? true)) LinkPreviewWidget( text: data.body['content'], - ).padding(horizontal: 4), + ).padding(left: 60, right: 4), _PostFeaturedComment(data: data, maxWidth: maxWidth) - .padding(horizontal: 12), - Container( - constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), - child: Column( - children: [ - _PostBottomAction( - data: data, - showComments: showComments, - showReactions: showReactions, - onShare: () => _doShare(context), - onShareImage: () => _doShareViaPicture(context), - onChanged: _onChanged, - ).padding(left: 8, right: 14), - ], + .padding(left: 60, right: 12), + Padding( + padding: const EdgeInsets.only(top: 4), + child: _PostReactionList( + data: data, + padding: const EdgeInsets.only(left: 60, right: 12), + onChanged: _onChanged, ), ), ], @@ -476,12 +440,7 @@ class PostShareImageWidget extends StatelessWidget { ), ).padding(bottom: 8), _PostContentHeader( - isAuthor: false, data: data, - onDeleted: () {}, - onShare: () {}, - onShareImage: () {}, - showMenu: false, isRelativeDate: false, ).padding(horizontal: 16, bottom: 8), if (data.type == 'question') @@ -516,14 +475,6 @@ class PostShareImageWidget extends StatelessWidget { _PostTruncatedHint(data: data), ], ).padding(horizontal: 16), - _PostBottomAction( - data: data, - showComments: true, - showReactions: true, - onShare: () {}, - onShareImage: () {}, - onChanged: (SnPost data) {}, - ).padding(left: 8, right: 14), const Divider(height: 1), const Gap(12), SizedBox( @@ -622,50 +573,90 @@ class _PostQuestionHint extends StatelessWidget { } } -class _PostBottomAction extends StatelessWidget { +class _PostReactionList extends StatefulWidget { final SnPost data; - final bool showComments; - final bool showReactions; - final Function(SnPost data) onChanged; - final Function() onShare, onShareImage; - - const _PostBottomAction({ + final EdgeInsets? padding; + final Function(SnPost) onChanged; + const _PostReactionList({ required this.data, - required this.showComments, - required this.showReactions, + this.padding, required this.onChanged, - required this.onShare, - required this.onShareImage, }); @override - Widget build(BuildContext context) { - final iconColor = Theme.of(context).colorScheme.onSurface.withAlpha( - (255 * 0.8).round(), + State<_PostReactionList> createState() => _PostReactionListState(); +} + +class _PostReactionListState extends State<_PostReactionList> { + bool _isSubmitting = false; + late int _totalUpvote = widget.data.totalUpvote; + late int _totalDownvote = widget.data.totalDownvote; + late Map _reactions = Map.from(widget.data.metric.reactionList); + + Future _reactPost(String symbol, int attitude) async { + if (_isSubmitting) return; + + final sn = context.read(); + + try { + setState(() => _isSubmitting = true); + final resp = await sn.client.post( + '/cgi/co/posts/${widget.data.id}/react', + data: { + 'symbol': symbol, + 'attitude': attitude, + }, + ); + if (resp.statusCode == 201) { + _reactions[symbol] = (_reactions[symbol] ?? 0) + 1; + widget.onChanged( + widget.data.copyWith( + metric: widget.data.metric.copyWith(reactionList: _reactions), + ), ); + } else if (resp.statusCode == 204) { + _reactions[symbol] = (_reactions[symbol] ?? 0) - 1; + widget.onChanged( + widget.data.copyWith( + metric: widget.data.metric.copyWith(reactionList: _reactions), + ), + ); + } + if (attitude == 1) { + setState(() => _totalUpvote += 1); + } else if (attitude == 2) { + setState(() => _totalDownvote += 1); + } + HapticFeedback.heavyImpact(); + } catch (err) { + if (mounted) context.showErrorDialog(err); + } finally { + setState(() => _isSubmitting = false); + } + } - final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty - ? data.metric.reactionList.entries - .reduce((a, b) => a.value > b.value ? a : b) - .key - : null; + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: ListView.separated( + padding: widget.padding, + itemCount: widget.data.metric.reactionList.length + 1, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + if (index == 0) { + final String? mostTypicalReaction = _reactions.isNotEmpty + ? _reactions.entries + .reduce((a, b) => a.value > b.value ? a : b) + .key + : null; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (showReactions || showComments) - Row( - spacing: 8, - children: [ - if (showReactions) - InkWell( - child: Row( - children: [ - if (mostTypicalReaction == null || - kTemplateReactions[mostTypicalReaction] == null) - Icon(Symbols.add_reaction, size: 20, color: iconColor) - else - Text( + return Row( + children: [ + ActionChip( + avatar: (kTemplateReactions[mostTypicalReaction] == null) + ? Icon(Symbols.add_reaction, size: 20) + : Text( kTemplateReactions[mostTypicalReaction]!.icon, style: TextStyle( fontSize: 16, @@ -673,81 +664,73 @@ class _PostBottomAction extends StatelessWidget { letterSpacing: 0, ), ), - const Gap(8), - if (data.totalUpvote > 0 && - data.totalUpvote >= data.totalDownvote) - Text('postReactionUpvote').plural( - data.totalUpvote, - ) - else if (data.totalDownvote > 0) - Text('postReactionDownvote').plural( - data.totalDownvote, - ) - else - Text('postReact').tr(), - ], - ).padding(horizontal: 8, vertical: 8), - onTap: () { + label: (_totalUpvote > 0 && _totalUpvote >= _totalDownvote) + ? Text('postReactionUpvote').plural(_totalUpvote) + : (_totalDownvote > 0) + ? Text('postReactionDownvote').plural(_totalDownvote) + : Text('postReact').tr(), + visualDensity: VisualDensity(horizontal: -4, vertical: -4), + onPressed: () { showModalBottomSheet( context: context, builder: (context) => PostReactionPopup( - data: data, + data: widget.data, onChanged: (value, attr, delta) { - onChanged(data.copyWith( + final metric = + widget.data.metric.copyWith(reactionList: value); + if (attr == 1) { + _totalUpvote += delta; + } else if (attr == 2) { + _totalDownvote += delta; + } + _reactions = Map.from(metric.reactionList); + widget.onChanged(widget.data.copyWith( totalUpvote: attr == 1 - ? data.totalUpvote + delta - : data.totalUpvote, + ? widget.data.totalUpvote + delta + : widget.data.totalUpvote, totalDownvote: attr == 2 - ? data.totalDownvote + delta - : data.totalDownvote, - metric: data.metric.copyWith(reactionList: value), + ? widget.data.totalDownvote + delta + : widget.data.totalDownvote, + metric: metric, )); }, ), ); }, ), - if (showComments) - InkWell( - child: Row( - children: [ - Icon(Symbols.comment, size: 20, color: iconColor), - const Gap(8), - Text('postComments').plural(data.metric.replyCount), - ], - ).padding(horizontal: 8, vertical: 8), - onTap: () { - showModalBottomSheet( - context: context, - useRootNavigator: true, - builder: (context) => PostCommentListPopup( - post: data, - commentCount: data.metric.replyCount, - ), - ); + if (_reactions.isNotEmpty) const Gap(8), + if (_reactions.isNotEmpty) + SizedBox( + width: 1, + height: 20, + child: const VerticalDivider(width: 1), + ), + if (_reactions.isNotEmpty) const Gap(4), + ], + ); + } + + final ele = _reactions.entries.elementAt(index - 1); + return ActionChip( + avatar: Text(kTemplateReactions[ele.key]!.icon), + label: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Text(ele.key), + Text('x${ele.value}').bold(), + ], + ), + visualDensity: VisualDensity(horizontal: -4, vertical: -4), + onPressed: _isSubmitting + ? null + : () { + _reactPost(ele.key, kTemplateReactions[ele.key]!.attitude); }, - ), - InkWell( - child: Row( - children: [ - Icon(Symbols.play_circle, size: 20, color: iconColor), - const Gap(8), - Text('postViews').plural(data.totalViews), - ], - ), - ), - ], - ), - InkWell( - onTap: onShare, - onLongPress: onShareImage, - child: Icon( - Symbols.share, - size: 20, - color: iconColor, - ).padding(horizontal: 8, vertical: 8), - ), - ], + ); + }, + separatorBuilder: (_, __) => const Gap(4), + ), ); } } @@ -849,22 +832,81 @@ class _PostHeadline extends StatelessWidget { } } -class _PostContentHeader extends StatelessWidget { +class _PostAvatar extends StatelessWidget { + final SnPost data; + final bool isCompact; + const _PostAvatar({required this.data, required this.isCompact}); + + @override + Widget build(BuildContext context) { + final ud = context.read(); + final user = data.publisher.type == 0 + ? ud.getFromCache(data.publisher.accountId) + : null; + + return GestureDetector( + child: data.preload?.realm == null + ? AccountImage( + content: data.publisher.avatar, + radius: isCompact ? 12 : 20, + borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20, + badge: (user?.badges.isNotEmpty ?? false) + ? AccountBadge( + badge: user!.badges.first, + radius: 16, + padding: EdgeInsets.all(2), + ) + : null, + ) + : AccountImage( + content: data.preload!.realm!.avatar, + radius: isCompact ? 12 : 20, + borderRadius: isCompact ? 4 : 8, + badgeOffset: Offset(-6, -4), + badge: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + borderRadius: BorderRadius.circular(10), + ), + child: AccountImage( + content: data.publisher.avatar, + radius: 10, + ), + ), + ), + onTap: () { + showPopover( + backgroundColor: Theme.of(context).colorScheme.surface, + context: context, + transition: PopoverTransition.other, + bodyBuilder: (context) => SizedBox( + width: math.min(400, MediaQuery.of(context).size.width - 10), + child: PublisherPopoverCard( + data: data.publisher, + ), + ), + direction: PopoverDirection.bottom, + arrowHeight: 5, + arrowWidth: 15, + arrowDxOffset: -190, + ); + }, + ); + } +} + +class _PostActionPopup extends StatelessWidget { final SnPost data; final bool isAuthor; - final bool isCompact; - final bool isRelativeDate; - final bool showMenu; final Function onDeleted; final Function() onShare, onShareImage; final Function()? onSelectAnswer; - - const _PostContentHeader({ + const _PostActionPopup({ required this.data, required this.isAuthor, - this.isCompact = false, - this.isRelativeDate = true, - this.showMenu = true, required this.onDeleted, required this.onShare, required this.onShareImage, @@ -914,281 +956,234 @@ class _PostContentHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final ud = context.read(); - final user = data.publisher.type == 0 - ? ud.getFromCache(data.publisher.accountId) - : null; - - return Row( - children: [ - GestureDetector( - child: data.preload?.realm == null - ? AccountImage( - content: data.publisher.avatar, - radius: isCompact ? 12 : 20, - borderRadius: - data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20, - badge: (user?.badges.isNotEmpty ?? false) - ? AccountBadge( - badge: user!.badges.first, - radius: 16, - padding: EdgeInsets.all(2), - ) - : null, - ) - : AccountImage( - content: data.preload!.realm!.avatar, - radius: isCompact ? 12 : 20, - borderRadius: isCompact ? 4 : 8, - badgeOffset: Offset(-6, -4), - badge: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.surface, - width: 2, - ), - borderRadius: BorderRadius.circular(10), - ), - child: AccountImage( - content: data.publisher.avatar, - radius: 10, - ), - ), - ), - onTap: () { - showPopover( - backgroundColor: Theme.of(context).colorScheme.surface, - context: context, - transition: PopoverTransition.other, - bodyBuilder: (context) => SizedBox( - width: math.min(400, MediaQuery.of(context).size.width - 10), - child: PublisherPopoverCard( - data: data.publisher, - ), - ), - direction: PopoverDirection.bottom, - arrowHeight: 5, - arrowWidth: 15, - arrowDxOffset: -190, - ); - }, + return SizedBox( + height: 20, + child: PopupMenuButton( + icon: const Icon(Symbols.more_horiz, size: 20), + style: const ButtonStyle( + visualDensity: VisualDensity(horizontal: -4, vertical: -4), ), - Gap(isCompact ? 8 : 12), - if (isCompact) - Row( - children: [ - Text(data.publisher.nick).bold(), - const Gap(4), - Row( + padding: EdgeInsets.zero, + itemBuilder: (BuildContext context) => [ + if (isAuthor && onSelectAnswer != null) + PopupMenuItem( + child: Row( children: [ - Text('@${data.publisher.name}').fontSize(13), - const Gap(4), - Text( - isRelativeDate - ? RelativeTime(context).format( - (data.publishedAt ?? data.createdAt).toLocal()) - : DateFormat('y/M/d HH:mm').format( - (data.publishedAt ?? data.createdAt).toLocal()), - ).fontSize(13), + const Icon(Symbols.check_circle), + const Gap(16), + Text('postQuestionAnswerSelect').tr(), ], - ).opacity(0.8), - ], - ) - else - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + onTap: () { + onSelectAnswer?.call(); + }, + ), + if (isAuthor && onSelectAnswer != null) PopupMenuDivider(), + if (isAuthor) + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.edit), + const Gap(16), + Text('edit').tr(), + ], + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'postEditor', + queryParameters: { + 'editing': data.id.toString(), + 'mode': data.typePlural, + }, + ); + }, + ), + if (isAuthor) + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.delete), + const Gap(16), + Text('delete').tr(), + ], + ), + onTap: () => _deletePost(context), + ), + if (isAuthor) const PopupMenuDivider(), + PopupMenuItem( + child: Row( children: [ - Row( - children: [ - Text(data.publisher.nick).bold(), - if (data.preload?.realm != null) - const Icon(Symbols.arrow_right, size: 16) - .padding(horizontal: 2) - .opacity(0.5), - if (data.preload?.realm != null) - Text(data.preload!.realm!.name), - ], - ), - Row( - children: [ - Text('@${data.publisher.name}').fontSize(13), - const Gap(4), - Text( - isRelativeDate - ? RelativeTime(context).format( - (data.publishedAt ?? data.createdAt).toLocal()) - : DateFormat('y/M/d HH:mm').format( - (data.publishedAt ?? data.createdAt).toLocal()), - ).fontSize(13), - ], - ).opacity(0.8), + const Icon(Symbols.reply), + const Gap(16), + Text('replyPost').tr(), + ], + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'postEditor', + queryParameters: { + 'replying': data.id.toString(), + 'mode': data.typePlural, + }, + ); + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.forward), + const Gap(16), + Text('repost').tr(), + ], + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'postEditor', + queryParameters: { + 'reposting': data.id.toString(), + 'mode': 'stories', + }, + ); + }, + ), + const PopupMenuDivider(), + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.book_4_spark), + const Gap(16), + Text('postGetInsight').tr(), + ], + ), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => _PostGetInsightPopup(postId: data.id), + ); + }, + ), + const PopupMenuDivider(), + PopupMenuItem( + onTap: onShare, + child: Row( + children: [ + const Icon(Symbols.share), + const Gap(16), + Text('postShare').tr(), ], ), ), - if (showMenu) - PopupMenuButton( - icon: const Icon(Symbols.more_horiz), - style: const ButtonStyle( - visualDensity: VisualDensity(horizontal: -4, vertical: -4), + PopupMenuItem( + onTap: onShareImage, + child: Row( + children: [ + const Icon(Symbols.share_reviews), + const Gap(16), + Text('postShareImage').tr(), + ], ), - itemBuilder: (BuildContext context) => [ - if (isAuthor && onSelectAnswer != null) - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.check_circle), - const Gap(16), - Text('postQuestionAnswerSelect').tr(), - ], - ), - onTap: () { - onSelectAnswer?.call(); - }, + ), + const PopupMenuDivider(), + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.flag), + const Gap(16), + Text('flagPostAction').tr(), + ], + ), + onTap: () { + _flagPost(context); + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.report), + const Gap(16), + Text('report').tr(), + ], + ), + onTap: () { + showDialog( + context: context, + builder: (context) => _PostAbuseReportDialog( + data: data, ), - if (isAuthor && onSelectAnswer != null) PopupMenuDivider(), - if (isAuthor) - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.edit), - const Gap(16), - Text('edit').tr(), - ], - ), - onTap: () { - GoRouter.of(context).pushNamed( - 'postEditor', - queryParameters: { - 'editing': data.id.toString(), - 'mode': data.typePlural, - }, - ); - }, - ), - if (isAuthor) - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.delete), - const Gap(16), - Text('delete').tr(), - ], - ), - onTap: () => _deletePost(context), - ), - if (isAuthor) const PopupMenuDivider(), - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.reply), - const Gap(16), - Text('replyPost').tr(), - ], - ), - onTap: () { - GoRouter.of(context).pushNamed( - 'postEditor', - queryParameters: { - 'replying': data.id.toString(), - 'mode': data.typePlural, - }, - ); - }, - ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.forward), - const Gap(16), - Text('repost').tr(), - ], - ), - onTap: () { - GoRouter.of(context).pushNamed( - 'postEditor', - queryParameters: { - 'reposting': data.id.toString(), - 'mode': 'stories', - }, - ); - }, - ), - const PopupMenuDivider(), - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.book_4_spark), - const Gap(16), - Text('postGetInsight').tr(), - ], - ), - onTap: () { - showModalBottomSheet( - context: context, - builder: (context) => _PostGetInsightPopup(postId: data.id), - ); - }, - ), - const PopupMenuDivider(), - PopupMenuItem( - onTap: onShare, - child: Row( - children: [ - const Icon(Symbols.share), - const Gap(16), - Text('postShare').tr(), - ], - ), - ), - PopupMenuItem( - onTap: onShareImage, - child: Row( - children: [ - const Icon(Symbols.share_reviews), - const Gap(16), - Text('postShareImage').tr(), - ], - ), - ), - const PopupMenuDivider(), - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.flag), - const Gap(16), - Text('flagPostAction').tr(), - ], - ), - onTap: () { - _flagPost(context); - }, - ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.report), - const Gap(16), - Text('report').tr(), - ], - ), - onTap: () { - showDialog( - context: context, - builder: (context) => _PostAbuseReportDialog( - data: data, - ), - ).then((value) { - if (value == true && context.mounted) { - context.showSnackbar('abuseReportSubmitted'.tr()); - } - }); - }, - ), + ).then((value) { + if (value == true && context.mounted) { + context.showSnackbar('abuseReportSubmitted'.tr()); + } + }); + }, + ), + ], + ), + ); + } +} + +class _PostContentHeader extends StatelessWidget { + final SnPost data; + final bool isCompact; + final bool isRelativeDate; + + const _PostContentHeader({ + required this.data, + this.isCompact = false, + this.isRelativeDate = true, + }); + + @override + Widget build(BuildContext context) { + if (isCompact) { + return Row( + children: [ + Text(data.publisher.nick).bold(), + const Gap(4), + Row( + children: [ + Text( + isRelativeDate + ? RelativeTime(context) + .format((data.publishedAt ?? data.createdAt).toLocal()) + : DateFormat('y/M/d HH:mm') + .format((data.publishedAt ?? data.createdAt).toLocal()), + ).fontSize(13), + ], + ).opacity(0.8), + ], + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(data.publisher.nick).bold(), + if (data.preload?.realm != null) + const Icon(Symbols.arrow_right, size: 16) + .padding(horizontal: 2) + .opacity(0.5), + if (data.preload?.realm != null) Text(data.preload!.realm!.name), ], ), - ], - ); + Row( + children: [ + Text('@${data.publisher.name}').fontSize(13), + const Gap(4), + Text( + isRelativeDate + ? RelativeTime(context) + .format((data.publishedAt ?? data.createdAt).toLocal()) + : DateFormat('y/M/d HH:mm') + .format((data.publishedAt ?? data.createdAt).toLocal()), + ).fontSize(13), + ], + ).opacity(0.8), + ], + ); + } } } @@ -1248,14 +1243,9 @@ class _PostQuoteContent extends StatelessWidget { Column( children: [ _PostContentHeader( - isAuthor: false, data: child, isCompact: true, isRelativeDate: isRelativeDate, - showMenu: false, - onShare: () {}, - onShareImage: () {}, - onDeleted: () {}, ).padding(bottom: 4), _PostContentBody(data: child), if (child.visibility > 0) @@ -1486,12 +1476,9 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> { @override Widget build(BuildContext context) { - if (widget.data.metric.replyCount == 0) return const SizedBox.shrink(); - if (_featuredComment == null) return const SizedBox.shrink(); + final ua = context.read(); - final sn = context.read(); - - return AnimateWidgetExtensions(Container( + return Container( constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity), margin: const EdgeInsets.only(top: 8), width: double.infinity, @@ -1499,7 +1486,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> { borderRadius: const BorderRadius.all(Radius.circular(8)), color: _isAnswer ? Colors.green.withOpacity(0.5) - : Theme.of(context).colorScheme.surfaceContainerHigh, + : Theme.of(context).colorScheme.surfaceContainer, child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(8)), onTap: () { @@ -1519,44 +1506,73 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> { crossAxisAlignment: CrossAxisAlignment.center, children: [ const Gap(2), - Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, - size: 20), + Transform.flip( + flipX: !_isAnswer, + child: Icon( + _isAnswer ? Symbols.task_alt : Symbols.reply, + size: 20, + ), + ), const Gap(10), Text( _isAnswer - ? 'postQuestionAnswerTitle' - : 'postFeaturedComment', + ? 'postQuestionAnswerTitle'.tr() + : 'postComments'.plural(widget.data.metric.replyCount), style: Theme.of(context) .textTheme .titleMedium! .copyWith(fontSize: 15), - ).tr(), + ), ], ), const Gap(4), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircleAvatar( - radius: 12, - backgroundImage: UniversalImage.provider( - sn.getAttachmentUrl(_featuredComment!.publisher.avatar), + if (_featuredComment != null) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AccountImage( + content: _featuredComment!.publisher.avatar, + radius: 12, ), - ), - const Gap(8), - Expanded( - child: MarkdownTextContent( - content: _featuredComment!.body['content'], - isAutoWarp: true, + const Gap(8), + Expanded( + child: MarkdownTextContent( + content: _featuredComment!.body['content'], + isAutoWarp: true, + ), + ) + ], + ) + else + Row( + children: [ + AccountImage( + content: ua.user?.avatar, + radius: 12, ), - ) - ], - ), + const Gap(8), + Expanded( + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + ), + child: Text('postCommentAdd').tr(), + ), + ), + ], + ), ], ).padding(horizontal: 16, vertical: 8), ), ), - )).animate().fadeIn(duration: 300.ms, curve: Curves.easeInOut); + ); } }