💄 Redesigned post item

This commit is contained in:
LittleSheep 2025-03-16 18:56:08 +08:00
parent ac2aec48aa
commit 9ec0f1ff19
7 changed files with 628 additions and 594 deletions

View File

@ -837,5 +837,6 @@
"fieldContactContent": "Contact method", "fieldContactContent": "Contact method",
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.", "accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
"accountContactMethodsDelete": "Delete Contact Method", "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"
} }

View File

@ -837,5 +837,6 @@
"fieldContactContent": "联系方式", "fieldContactContent": "联系方式",
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。", "accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
"accountContactMethodsDelete": "删除联系方式", "accountContactMethodsDelete": "删除联系方式",
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。" "accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
"postCommentAdd": "撰写一条评论"
} }

View File

@ -837,5 +837,6 @@
"fieldContactContent": "聯繫方式", "fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。" "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論"
} }

View File

@ -837,5 +837,6 @@
"fieldContactContent": "聯繫方式", "fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。", "accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式", "accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。" "accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論"
} }

View File

@ -551,12 +551,18 @@ class _PostListWidgetState extends State<_PostListWidget> {
maxWidth: 640, maxWidth: 640,
); );
case 'reader.news': case 'reader.news':
return NewsFeedEntry(data: ele); return Container(
constraints: BoxConstraints(maxWidth: 640),
child: NewsFeedEntry(data: ele),
);
default: 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),
), ),
), ),
); );

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.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:surface/widgets/post/post_mini_editor.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/sn_network.dart';
class PostCommentQuickAction extends StatelessWidget { class PostCommentQuickAction extends StatelessWidget {
final double? maxWidth; final double? maxWidth;
final SnPost parentPost; final SnPost parentPost;
final Function? onPosted; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -30,7 +30,9 @@ class PostCommentQuickAction extends StatelessWidget {
return Container( return Container(
height: 240, height: 240,
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), 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( decoration: BoxDecoration(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE) borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8)) ? const BorderRadius.all(Radius.circular(8))
@ -99,7 +101,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
Future<void> _selectAnswer(SnPost answer) async { Future<void> _selectAnswer(SnPost answer) async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
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, 'publisher': answer.publisherId,
'answer_id': answer.id, 'answer_id': answer.id,
}); });
@ -135,7 +138,9 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
child: PostItem( child: PostItem(
data: _posts[idx], data: _posts[idx],
maxWidth: widget.maxWidth, maxWidth: widget.maxWidth,
onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null, onSelectAnswer: widget.parentPost.type == 'question'
? () => _selectAnswer(_posts[idx])
: null,
onChanged: (data) { onChanged: (data) {
setState(() => _posts[idx] = data); setState(() => _posts[idx] = data);
}, },
@ -153,7 +158,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
}, },
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (context, index) =>
const Divider().padding(vertical: 2),
); );
} }
} }
@ -188,7 +194,9 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
children: [ children: [
const Icon(Symbols.comment, size: 24), const Icon(Symbols.comment, size: 24),
const Gap(16), 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), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(

View File

@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -72,7 +72,9 @@ class OpenablePostItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
return Center( return Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Center(
child: OpenContainer( child: OpenContainer(
closedBuilder: (_, __) => Container( closedBuilder: (_, __) => Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
@ -94,14 +96,15 @@ class OpenablePostItem extends StatelessWidget {
openColor: Colors.transparent, openColor: Colors.transparent,
openElevation: 0, openElevation: 0,
transitionType: ContainerTransitionType.fade, transitionType: ContainerTransitionType.fade,
closedColor: closedElevation: 0,
Theme.of(context).colorScheme.surfaceContainerLow.withOpacity( closedColor: Theme.of(context).colorScheme.surface.withOpacity(
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
), ),
closedShape: const RoundedRectangleBorder( closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(Radius.circular(16)),
), ),
), ),
),
); );
} }
} }
@ -202,61 +205,6 @@ class PostItem extends StatelessWidget {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id; 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') { if (!showFullPost && data.type == 'article') {
return Container( return Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
@ -265,14 +213,7 @@ class PostItem extends StatelessWidget {
children: [ children: [
_PostContentHeader( _PostContentHeader(
data: data, data: data,
isAuthor: isAuthor,
isRelativeDate: !showFullPost, isRelativeDate: !showFullPost,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
onDeleted?.call();
},
).padding(horizontal: 12, top: 8, bottom: 8), ).padding(horizontal: 12, top: 8, bottom: 8),
if (data.preload?.video != null) if (data.preload?.video != null)
_PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8), _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
@ -325,14 +266,6 @@ class PostItem extends StatelessWidget {
.padding(horizontal: 24, bottom: 8), .padding(horizontal: 24, bottom: 8),
_PostFeaturedComment(data: data, maxWidth: maxWidth) _PostFeaturedComment(data: data, maxWidth: maxWidth)
.padding(horizontal: 12), .padding(horizontal: 12),
_PostBottomAction(
data: data,
showComments: showComments,
showReactions: showReactions,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onChanged: _onChanged,
).padding(left: 8, right: 14),
], ],
), ),
).center(); ).center();
@ -345,6 +278,12 @@ class PostItem extends StatelessWidget {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
var attachmentSize = math.min(
MediaQuery.of(context).size.width, maxWidth ?? double.infinity);
if ((data.preload?.attachments?.length ?? 0) > 1) {
attachmentSize -= 80;
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -353,54 +292,85 @@ class PostItem extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_PostContentHeader( Row(
isAuthor: isAuthor, crossAxisAlignment: CrossAxisAlignment.start,
isRelativeDate: !showFullPost, children: [
_PostAvatar(
data: data, data: data,
showMenu: showMenu, 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), onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer, onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
onDeleted?.call(); onDeleted?.call();
}, },
).padding(horizontal: 12, vertical: 8), ),
],
),
const Gap(8),
if (data.preload?.video != null) if (data.preload?.video != null)
_PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8), _PostVideoPlayer(data: data).padding(bottom: 8),
if (data.type == 'question') if (data.type == 'question')
_PostQuestionHint(data: data) _PostQuestionHint(data: data).padding(bottom: 8),
.padding(horizontal: 16, bottom: 8),
if (data.body['title'] != null || if (data.body['title'] != null ||
data.body['description'] != null) data.body['description'] != null)
_PostHeadline( _PostHeadline(
data: data, data: data,
isEnlarge: data.type == 'article' && showFullPost, isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 8), ).padding(bottom: 8),
if (data.body['content']?.isNotEmpty ?? false) if (data.body['content']?.isNotEmpty ?? false)
_PostContentBody( _PostContentBody(
data: data, data: data,
isSelectable: showFullPost, isSelectable: showFullPost,
isEnlarge: data.type == 'article' && showFullPost, isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 6), ).padding(bottom: 6),
if (data.repostTo != null) if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding( _PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12,
bottom: bottom:
data.preload?.attachments?.isNotEmpty ?? false ? 12 : 0, data.preload?.attachments?.isNotEmpty ?? false
? 12
: 0,
), ),
if (data.visibility > 0) if (data.visibility > 0)
_PostVisibilityHint(data: data).padding( _PostVisibilityHint(data: data).padding(
horizontal: 16,
vertical: 4, vertical: 4,
), ),
if (data.body['content_truncated'] == true) if (data.body['content_truncated'] == true)
_PostTruncatedHint(data: data).padding( _PostTruncatedHint(data: data).padding(
horizontal: 16,
vertical: 4, vertical: 4,
), ),
if (data.tags.isNotEmpty) if (data.tags.isNotEmpty)
_PostTagsList(data: data) _PostTagsList(data: data).padding(top: 4, bottom: 6),
.padding(horizontal: 16, 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!, data: displayableAttachments!,
bordered: true, bordered: true,
maxHeight: showFullPost ? null : 480, maxHeight: showFullPost ? null : 480,
maxWidth: MediaQuery.of(context).size.width - 20, minWidth: attachmentSize,
maxWidth: attachmentSize,
fit: showFullPost ? BoxFit.cover : BoxFit.contain, 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) if (data.preload?.poll != null)
PostPoll(poll: data.preload!.poll!) PostPoll(poll: data.preload!.poll!)
@ -420,22 +391,15 @@ class PostItem extends StatelessWidget {
(cfg.prefs.getBool(kAppExpandPostLink) ?? true)) (cfg.prefs.getBool(kAppExpandPostLink) ?? true))
LinkPreviewWidget( LinkPreviewWidget(
text: data.body['content'], text: data.body['content'],
).padding(horizontal: 4), ).padding(left: 60, right: 4),
_PostFeaturedComment(data: data, maxWidth: maxWidth) _PostFeaturedComment(data: data, maxWidth: maxWidth)
.padding(horizontal: 12), .padding(left: 60, right: 12),
Container( Padding(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), padding: const EdgeInsets.only(top: 4),
child: Column( child: _PostReactionList(
children: [
_PostBottomAction(
data: data, data: data,
showComments: showComments, padding: const EdgeInsets.only(left: 60, right: 12),
showReactions: showReactions,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onChanged: _onChanged, onChanged: _onChanged,
).padding(left: 8, right: 14),
],
), ),
), ),
], ],
@ -476,12 +440,7 @@ class PostShareImageWidget extends StatelessWidget {
), ),
).padding(bottom: 8), ).padding(bottom: 8),
_PostContentHeader( _PostContentHeader(
isAuthor: false,
data: data, data: data,
onDeleted: () {},
onShare: () {},
onShareImage: () {},
showMenu: false,
isRelativeDate: false, isRelativeDate: false,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.type == 'question') if (data.type == 'question')
@ -516,14 +475,6 @@ class PostShareImageWidget extends StatelessWidget {
_PostTruncatedHint(data: data), _PostTruncatedHint(data: data),
], ],
).padding(horizontal: 16), ).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 Divider(height: 1),
const Gap(12), const Gap(12),
SizedBox( SizedBox(
@ -622,50 +573,90 @@ class _PostQuestionHint extends StatelessWidget {
} }
} }
class _PostBottomAction extends StatelessWidget { class _PostReactionList extends StatefulWidget {
final SnPost data; final SnPost data;
final bool showComments; final EdgeInsets? padding;
final bool showReactions; final Function(SnPost) onChanged;
final Function(SnPost data) onChanged; const _PostReactionList({
final Function() onShare, onShareImage;
const _PostBottomAction({
required this.data, required this.data,
required this.showComments, this.padding,
required this.showReactions,
required this.onChanged, required this.onChanged,
required this.onShare,
required this.onShareImage,
}); });
@override @override
Widget build(BuildContext context) { State<_PostReactionList> createState() => _PostReactionListState();
final iconColor = Theme.of(context).colorScheme.onSurface.withAlpha( }
(255 * 0.8).round(),
);
final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty class _PostReactionListState extends State<_PostReactionList> {
? data.metric.reactionList.entries bool _isSubmitting = false;
late int _totalUpvote = widget.data.totalUpvote;
late int _totalDownvote = widget.data.totalDownvote;
late Map<String, int> _reactions = Map.from(widget.data.metric.reactionList);
Future<void> _reactPost(String symbol, int attitude) async {
if (_isSubmitting) return;
final sn = context.read<SnNetworkProvider>();
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);
}
}
@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) .reduce((a, b) => a.value > b.value ? a : b)
.key .key
: null; : null;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
if (showReactions || showComments) ActionChip(
Row( avatar: (kTemplateReactions[mostTypicalReaction] == null)
spacing: 8, ? Icon(Symbols.add_reaction, size: 20)
children: [ : Text(
if (showReactions)
InkWell(
child: Row(
children: [
if (mostTypicalReaction == null ||
kTemplateReactions[mostTypicalReaction] == null)
Icon(Symbols.add_reaction, size: 20, color: iconColor)
else
Text(
kTemplateReactions[mostTypicalReaction]!.icon, kTemplateReactions[mostTypicalReaction]!.icon,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@ -673,81 +664,73 @@ class _PostBottomAction extends StatelessWidget {
letterSpacing: 0, letterSpacing: 0,
), ),
), ),
const Gap(8), label: (_totalUpvote > 0 && _totalUpvote >= _totalDownvote)
if (data.totalUpvote > 0 && ? Text('postReactionUpvote').plural(_totalUpvote)
data.totalUpvote >= data.totalDownvote) : (_totalDownvote > 0)
Text('postReactionUpvote').plural( ? Text('postReactionDownvote').plural(_totalDownvote)
data.totalUpvote, : Text('postReact').tr(),
) visualDensity: VisualDensity(horizontal: -4, vertical: -4),
else if (data.totalDownvote > 0) onPressed: () {
Text('postReactionDownvote').plural(
data.totalDownvote,
)
else
Text('postReact').tr(),
],
).padding(horizontal: 8, vertical: 8),
onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => PostReactionPopup( builder: (context) => PostReactionPopup(
data: data, data: widget.data,
onChanged: (value, attr, delta) { 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 totalUpvote: attr == 1
? data.totalUpvote + delta ? widget.data.totalUpvote + delta
: data.totalUpvote, : widget.data.totalUpvote,
totalDownvote: attr == 2 totalDownvote: attr == 2
? data.totalDownvote + delta ? widget.data.totalDownvote + delta
: data.totalDownvote, : widget.data.totalDownvote,
metric: data.metric.copyWith(reactionList: value), metric: metric,
)); ));
}, },
), ),
); );
}, },
), ),
if (showComments) if (_reactions.isNotEmpty) const Gap(8),
InkWell( if (_reactions.isNotEmpty)
child: Row( SizedBox(
children: [ width: 1,
Icon(Symbols.comment, size: 20, color: iconColor), height: 20,
const Gap(8), child: const VerticalDivider(width: 1),
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(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);
},
); );
}, },
separatorBuilder: (_, __) => const Gap(4),
), ),
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),
),
],
); );
} }
} }
@ -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<UserDirectoryProvider>();
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 SnPost data;
final bool isAuthor; final bool isAuthor;
final bool isCompact;
final bool isRelativeDate;
final bool showMenu;
final Function onDeleted; final Function onDeleted;
final Function() onShare, onShareImage; final Function() onShare, onShareImage;
final Function()? onSelectAnswer; final Function()? onSelectAnswer;
const _PostActionPopup({
const _PostContentHeader({
required this.data, required this.data,
required this.isAuthor, required this.isAuthor,
this.isCompact = false,
this.isRelativeDate = true,
this.showMenu = true,
required this.onDeleted, required this.onDeleted,
required this.onShare, required this.onShare,
required this.onShareImage, required this.onShareImage,
@ -914,124 +956,14 @@ class _PostContentHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>(); return SizedBox(
final user = data.publisher.type == 0 height: 20,
? ud.getFromCache(data.publisher.accountId) child: PopupMenuButton(
: null; icon: const Icon(Symbols.more_horiz, size: 20),
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,
);
},
),
Gap(isCompact ? 8 : 12),
if (isCompact)
Row(
children: [
Text(data.publisher.nick).bold(),
const Gap(4),
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),
],
)
else
Expanded(
child: 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),
],
),
),
if (showMenu)
PopupMenuButton(
icon: const Icon(Symbols.more_horiz),
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4), visualDensity: VisualDensity(horizontal: -4, vertical: -4),
), ),
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context) => <PopupMenuEntry>[ itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (isAuthor && onSelectAnswer != null) if (isAuthor && onSelectAnswer != null)
PopupMenuItem( PopupMenuItem(
@ -1187,8 +1119,71 @@ class _PostContentHeader extends StatelessWidget {
), ),
], ],
), ),
);
}
}
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( Column(
children: [ children: [
_PostContentHeader( _PostContentHeader(
isAuthor: false,
data: child, data: child,
isCompact: true, isCompact: true,
isRelativeDate: isRelativeDate, isRelativeDate: isRelativeDate,
showMenu: false,
onShare: () {},
onShareImage: () {},
onDeleted: () {},
).padding(bottom: 4), ).padding(bottom: 4),
_PostContentBody(data: child), _PostContentBody(data: child),
if (child.visibility > 0) if (child.visibility > 0)
@ -1486,12 +1476,9 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.data.metric.replyCount == 0) return const SizedBox.shrink(); final ua = context.read<UserProvider>();
if (_featuredComment == null) return const SizedBox.shrink();
final sn = context.read<SnNetworkProvider>(); return Container(
return AnimateWidgetExtensions(Container(
constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
margin: const EdgeInsets.only(top: 8), margin: const EdgeInsets.only(top: 8),
width: double.infinity, width: double.infinity,
@ -1499,7 +1486,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
color: _isAnswer color: _isAnswer
? Colors.green.withOpacity(0.5) ? Colors.green.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainerHigh, : Theme.of(context).colorScheme.surfaceContainer,
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () { onTap: () {
@ -1519,29 +1506,33 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Gap(2), const Gap(2),
Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, Transform.flip(
size: 20), flipX: !_isAnswer,
child: Icon(
_isAnswer ? Symbols.task_alt : Symbols.reply,
size: 20,
),
),
const Gap(10), const Gap(10),
Text( Text(
_isAnswer _isAnswer
? 'postQuestionAnswerTitle' ? 'postQuestionAnswerTitle'.tr()
: 'postFeaturedComment', : 'postComments'.plural(widget.data.metric.replyCount),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium! .titleMedium!
.copyWith(fontSize: 15), .copyWith(fontSize: 15),
).tr(), ),
], ],
), ),
const Gap(4), const Gap(4),
if (_featuredComment != null)
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircleAvatar( AccountImage(
content: _featuredComment!.publisher.avatar,
radius: 12, radius: 12,
backgroundImage: UniversalImage.provider(
sn.getAttachmentUrl(_featuredComment!.publisher.avatar),
),
), ),
const Gap(8), const Gap(8),
Expanded( Expanded(
@ -1551,12 +1542,37 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
), ),
) )
], ],
)
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), ).padding(horizontal: 16, vertical: 8),
), ),
), ),
)).animate().fadeIn(duration: 300.ms, curve: Curves.easeInOut); );
} }
} }