💄 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",
"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"
}

View File

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

View File

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

View File

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

View File

@ -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),
),
),
);

View File

@ -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<PostCommentSliverList> {
Future<void> _selectAnswer(SnPost answer) async {
try {
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,
'answer_id': answer.id,
});
@ -135,7 +138,9 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
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<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: [
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(

View File

@ -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,7 +72,9 @@ class OpenablePostItem extends StatelessWidget {
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return Center(
return Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Center(
child: OpenContainer(
closedBuilder: (_, __) => Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
@ -94,14 +96,15 @@ class OpenablePostItem extends StatelessWidget {
openColor: Colors.transparent,
openElevation: 0,
transitionType: ContainerTransitionType.fade,
closedColor:
Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
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)),
),
),
),
);
}
}
@ -202,61 +205,6 @@ class PostItem extends StatelessWidget {
final ua = context.read<UserProvider>();
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<ConfigProvider>();
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,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PostAvatar(
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),
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
onDeleted?.call();
},
).padding(horizontal: 12, vertical: 8),
),
],
),
const Gap(8),
if (data.preload?.video != null)
_PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
_PostVideoPlayer(data: data).padding(bottom: 8),
if (data.type == 'question')
_PostQuestionHint(data: data)
.padding(horizontal: 16, bottom: 8),
_PostQuestionHint(data: data).padding(bottom: 8),
if (data.body['title'] != null ||
data.body['description'] != null)
_PostHeadline(
data: data,
isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 8),
).padding(bottom: 8),
if (data.body['content']?.isNotEmpty ?? false)
_PostContentBody(
data: data,
isSelectable: showFullPost,
isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 6),
).padding(bottom: 6),
if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12,
bottom:
data.preload?.attachments?.isNotEmpty ?? false ? 12 : 0,
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),
_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(
.padding(left: 60, right: 12),
Padding(
padding: const EdgeInsets.only(top: 4),
child: _PostReactionList(
data: data,
showComments: showComments,
showReactions: showReactions,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
padding: const EdgeInsets.only(left: 60, right: 12),
onChanged: _onChanged,
).padding(left: 8, right: 14),
],
),
),
],
@ -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();
}
final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty
? data.metric.reactionList.entries
class _PostReactionListState extends State<_PostReactionList> {
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)
.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(
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);
},
);
},
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 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,124 +956,14 @@ class _PostContentHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
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,
);
},
),
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),
return SizedBox(
height: 20,
child: PopupMenuButton(
icon: const Icon(Symbols.more_horiz, size: 20),
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (isAuthor && onSelectAnswer != null)
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(
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<UserProvider>();
final sn = context.read<SnNetworkProvider>();
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,29 +1506,33 @@ 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),
if (_featuredComment != null)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
AccountImage(
content: _featuredComment!.publisher.avatar,
radius: 12,
backgroundImage: UniversalImage.provider(
sn.getAttachmentUrl(_featuredComment!.publisher.avatar),
),
),
const Gap(8),
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),
),
),
)).animate().fadeIn(duration: 300.ms, curve: Curves.easeInOut);
);
}
}