diff --git a/lib/community/widgets/post_card_type_badge.dart b/lib/community/widgets/post_card_type_badge.dart deleted file mode 100644 index e6ca07f26..000000000 --- a/lib/community/widgets/post_card_type_badge.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/core/enums/media_type.dart'; -import 'package:thunder/core/theme/bloc/theme_bloc.dart'; - -/// Base representation of a media type badge. Holds the icon and color. -class MediaTypeBadgeItem { - /// The icon associated with the media type - final Icon icon; - - /// The color associated with the media type - final Color baseColor; - - const MediaTypeBadgeItem({required this.baseColor, required this.icon}); -} - -class TypeBadge extends StatelessWidget { - /// Determines whether the badge should be dimmed or not. This is usually to indicate when a post has been read. - final bool dim; - - /// The media type of the badge. This is used to determine the badge color and icon. - final MediaType mediaType; - - const TypeBadge({super.key, required this.dim, required this.mediaType}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bool darkTheme = context.read().state.useDarkTheme; - const borderRadius = BorderRadius.only(topLeft: Radius.circular(15), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(12), topRight: Radius.circular(4)); - - Map mediaTypeItems = { - MediaType.text: MediaTypeBadgeItem( - baseColor: Colors.green, - icon: Icon(size: 17, Icons.wysiwyg_rounded, color: getIconColor(theme, Colors.green)), - ), - MediaType.link: MediaTypeBadgeItem( - baseColor: Colors.blue, - icon: Icon(size: 19, Icons.link_rounded, color: getIconColor(theme, Colors.blue)), - ), - MediaType.image: MediaTypeBadgeItem( - baseColor: Colors.red, - icon: Icon(size: 17, Icons.image_outlined, color: getIconColor(theme, Colors.red)), - ), - MediaType.video: MediaTypeBadgeItem( - baseColor: Colors.purple, - icon: Icon(size: 17, Icons.play_arrow_rounded, color: getIconColor(theme, Colors.purple)), - ), - }; - - return SizedBox( - height: 28, - width: 28, - child: Material( - borderRadius: borderRadius, - // This is the thin sliver between the badge and the preview. - // It should be made to match the read background color in the compact file. - color: dim ? Color.alphaBlend(theme.colorScheme.onSurface.withValues(alpha: darkTheme ? 0.05 : 0.075), theme.colorScheme.surface) : theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.only(left: 2.5, top: 2.5), - child: switch (mediaType) { - MediaType.text => typeBadgeItem(context, mediaTypeItems[MediaType.text]!), - MediaType.link => typeBadgeItem(context, mediaTypeItems[MediaType.link]!), - MediaType.image => typeBadgeItem(context, mediaTypeItems[MediaType.image]!), - MediaType.video => typeBadgeItem(context, mediaTypeItems[MediaType.video]!), - }, - ), - ), - ); - } - - Widget typeBadgeItem(context, MediaTypeBadgeItem mediaTypeBadgeItem) { - final theme = Theme.of(context); - const innerBorderRadius = BorderRadius.only(topLeft: Radius.circular(12), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(12), topRight: Radius.circular(4)); - - return Material( - borderRadius: innerBorderRadius, - color: getMaterialColor(theme, mediaTypeBadgeItem.baseColor), - child: mediaTypeBadgeItem.icon, - ); - } - - Color getMaterialColor(ThemeData theme, Color blendColor) { - return Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.6), blendColor).withValues(alpha: dim ? 0.55 : 1); - } - - Color getIconColor(ThemeData theme, Color blendColor) { - return Color.alphaBlend(theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.9), blendColor).withValues(alpha: dim ? 0.55 : 1); - } -} diff --git a/lib/community/widgets/post_card_view_compact.dart b/lib/community/widgets/post_card_view_compact.dart index 5b8ab4394..b8e50080d 100644 --- a/lib/community/widgets/post_card_view_compact.dart +++ b/lib/community/widgets/post_card_view_compact.dart @@ -2,30 +2,41 @@ import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:html_unescape/html_unescape_small.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/community/widgets/post_card_metadata.dart'; -import 'package:thunder/community/widgets/post_card_type_badge.dart'; -import 'package:thunder/core/auth/bloc/auth_bloc.dart'; -import 'package:thunder/core/enums/font_scale.dart'; import 'package:thunder/core/enums/media_type.dart'; import 'package:thunder/core/enums/view_mode.dart'; import 'package:thunder/core/models/post_view_media.dart'; import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/feed/view/feed_page.dart'; -import 'package:thunder/shared/media/media_view.dart'; +import 'package:thunder/post/widgets/post_card_title.dart'; +import 'package:thunder/shared/media/compact_thumbnail_preview.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +/// Displays a compact view of a post card. This view is used in the feed related pages. class PostCardViewCompact extends StatelessWidget { + /// The associated post information to display in the card. final PostViewMedia postViewMedia; + + /// The type of feed that the post is in. final FeedType? feedType; + + /// Determines whether the user is logged in or not. final bool isUserLoggedIn; + + /// The type of listing that the post is in. final ListingType? listingType; + + /// The callback function to navigate to the post. final void Function({PostViewMedia? postViewMedia})? navigateToPost; + + /// Determines whether the post should be dimmed or not. This is usually to indicate when a post has been read. final bool? indicateRead; + + /// Determines whether the media thumbnails should be shown or not. final bool showMedia; + + /// Determines whether the post is the last tapped post. This is used to highlight the post. final bool isLastTapped; const PostCardViewCompact({ @@ -40,131 +51,67 @@ class PostCardViewCompact extends StatelessWidget { required this.isLastTapped, }); - @override - Widget build(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; + /// Returns the color of the container based on the current theme and whether the post is dimmed or not. + /// + /// If the post is the last tapped post, the container will be highlighted with the primary color. + Color? getContainerColor(BuildContext context, {bool dim = false}) { final theme = Theme.of(context); - final ThunderState state = context.watch().state; + final useDarkTheme = context.select((ThemeBloc bloc) => bloc.state.useDarkTheme); + + if (isLastTapped) { + return theme.colorScheme.primary.withValues(alpha: 0.15); + } else if (dim) { + return theme.colorScheme.onSurface.withValues(alpha: useDarkTheme ? 0.05 : 0.075); + } - bool showThumbnailPreviewOnRight = state.showThumbnailPreviewOnRight; - bool showTextPostIndicator = state.showTextPostIndicator; - bool indicateRead = this.indicateRead ?? state.dimReadPosts; + return null; + } - final showCommunitySubscription = (listingType == ListingType.all || listingType == ListingType.local) && - isUserLoggedIn && - context.read().state.subsciptions.map((subscription) => subscription.community.actorId).contains(postViewMedia.postView.community.actorId); + @override + Widget build(BuildContext context) { + final showThumbnailPreviewOnRight = context.select((ThunderBloc bloc) => bloc.state.showThumbnailPreviewOnRight); + final showTextPostIndicator = context.select((ThunderBloc bloc) => bloc.state.showTextPostIndicator); + final showCommunitySubscription = isUserLoggedIn && (listingType == ListingType.all || listingType == ListingType.local) && postViewMedia.postView.subscribed != SubscribedType.notSubscribed; - Color? communityAndAuthorColorTransformation(Color? color) => indicateRead && postViewMedia.postView.read ? color?.withValues(alpha: 0.45) : color?.withValues(alpha: 0.75); + bool indicateRead = this.indicateRead ?? context.select((ThunderBloc bloc) => bloc.state.dimReadPosts); - final double textScaleFactor = state.titleFontSizeScale.textScaleFactor; + // Post statuses + final read = postViewMedia.postView.read; + final hidden = postViewMedia.postView.hidden; + final removed = postViewMedia.postView.post.removed; + final deleted = postViewMedia.postView.post.deleted; + final saved = postViewMedia.postView.saved; + final locked = postViewMedia.postView.post.locked; + final pinned = postViewMedia.postView.post.featuredCommunity || postViewMedia.postView.post.featuredLocal; - final bool darkTheme = context.read().state.useDarkTheme; + Color? communityAndAuthorColorTransformation(Color? color) => indicateRead && read ? color?.withValues(alpha: 0.45) : color?.withValues(alpha: 0.75); + + final dim = indicateRead && read; return Container( - color: isLastTapped - ? theme.colorScheme.primary.withValues(alpha: 0.15) - : indicateRead && postViewMedia.postView.read - ? theme.colorScheme.onSurface.withValues(alpha: darkTheme ? 0.05 : 0.075) - : null, + color: getContainerColor(context, dim: dim), padding: showMedia ? const EdgeInsets.only(bottom: 8.0, top: 6) : const EdgeInsets.only(left: 4.0, top: 10.0, bottom: 10.0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ !showThumbnailPreviewOnRight && showMedia && (postViewMedia.media.first.mediaType == MediaType.text ? showTextPostIndicator : true) - ? ThumbnailPreview( - postViewMedia: postViewMedia, - navigateToPost: navigateToPost, - indicateRead: indicateRead, - ) + ? CompactThumbnailPreview(media: postViewMedia.media.first, dim: dim, navigateToPost: navigateToPost) : const SizedBox(width: 8.0), Expanded( child: Column( + spacing: 6.0, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text.rich( - TextSpan( - children: [ - if (postViewMedia.postView.hidden == true) ...[ - WidgetSpan( - child: Icon( - Icons.visibility_off_rounded, - color: indicateRead && postViewMedia.postView.read - ? context.read().state.hideColor.color.withValues(alpha: 0.55) - : context.read().state.hideColor.color, - size: 16 * textScaleFactor, - semanticLabel: l10n.hidden, - ), - ), - const WidgetSpan(child: SizedBox(width: 2)), - ], - if (postViewMedia.postView.post.locked) ...[ - WidgetSpan( - child: Icon( - Icons.lock, - color: indicateRead && postViewMedia.postView.read - ? context.read().state.upvoteColor.color.withValues(alpha: 0.55) - : context.read().state.upvoteColor.color, - size: 15 * textScaleFactor, - ), - ), - ], - if (postViewMedia.postView.saved) - WidgetSpan( - child: Icon( - Icons.star_rounded, - color: indicateRead && postViewMedia.postView.read - ? context.read().state.saveColor.color.withValues(alpha: 0.55) - : context.read().state.saveColor.color, - size: 17 * textScaleFactor, - semanticLabel: 'Saved', - ), - ), - if (postViewMedia.postView.post.featuredCommunity || postViewMedia.postView.post.featuredLocal) - WidgetSpan( - child: Icon( - Icons.push_pin_rounded, - size: 15 * textScaleFactor, - color: indicateRead && postViewMedia.postView.read ? Colors.green.withValues(alpha: 0.55) : Colors.green, - ), - ), - if (postViewMedia.postView.post.deleted) - WidgetSpan( - child: Icon( - Icons.delete_rounded, - size: 16 * textScaleFactor, - color: indicateRead && postViewMedia.postView.read ? Colors.red.withValues(alpha: 0.55) : Colors.red, - ), - ), - if (postViewMedia.postView.post.removed) - WidgetSpan( - child: Icon( - Icons.delete_forever_rounded, - size: 16 * textScaleFactor, - color: indicateRead && postViewMedia.postView.read ? Colors.red.withValues(alpha: 0.55) : Colors.red, - ), - ), - if (postViewMedia.postView.post.deleted || - postViewMedia.postView.post.removed || - postViewMedia.postView.post.featuredCommunity || - postViewMedia.postView.post.featuredLocal || - postViewMedia.postView.saved || - postViewMedia.postView.post.locked) - const WidgetSpan(child: SizedBox(width: 3.5)), - TextSpan( - text: HtmlUnescape().convert(postViewMedia.postView.post.name), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: MediaQuery.textScalerOf(context).scale(theme.textTheme.bodyMedium!.fontSize! * state.titleFontSizeScale.textScaleFactor), - color: postViewMedia.postView.post.featuredCommunity || postViewMedia.postView.post.featuredLocal - ? (indicateRead && postViewMedia.postView.read ? Colors.green.withValues(alpha: 0.55) : Colors.green) - : (indicateRead && postViewMedia.postView.read ? theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.55) : null), - ), - ), - ], - ), - textScaler: TextScaler.noScaling, + PostCardTitle( + title: postViewMedia.postView.post.name, + hidden: hidden ?? false, + locked: locked, + saved: saved, + pinned: pinned, + deleted: deleted, + removed: removed, + dim: dim, ), - const SizedBox(height: 6.0), PostCommunityAndAuthor( compactMode: true, showCommunityIcons: false, @@ -174,7 +121,6 @@ class PostCardViewCompact extends StatelessWidget { authorColorTransformation: communityAndAuthorColorTransformation, showCommunitySubscription: showCommunitySubscription, ), - const SizedBox(height: 6.0), PostCardMetadata( postCardViewType: ViewMode.compact, score: postViewMedia.postView.counts.score, @@ -187,77 +133,16 @@ class PostCardViewCompact extends StatelessWidget { hasBeenEdited: postViewMedia.postView.post.updated != null ? true : false, url: postViewMedia.media.firstOrNull != null ? postViewMedia.media.first.originalUrl : null, languageId: postViewMedia.postView.post.languageId, - hasBeenRead: indicateRead && postViewMedia.postView.read, + hasBeenRead: dim, ), ], ), ), showThumbnailPreviewOnRight && showMedia && (postViewMedia.media.first.mediaType == MediaType.text ? showTextPostIndicator : true) - ? ThumbnailPreview( - postViewMedia: postViewMedia, - navigateToPost: navigateToPost, - indicateRead: indicateRead, - ) + ? CompactThumbnailPreview(media: postViewMedia.media.first, dim: dim, navigateToPost: navigateToPost) : const SizedBox(width: 8.0), ], ), ); } } - -/// Displays the thumbnail preview for the post. This can be text, media, or links. -class ThumbnailPreview extends StatelessWidget { - /// The [PostViewMedia] to display the thumbnail preview for - final PostViewMedia postViewMedia; - - /// The callback function to navigate to the post - final void Function({PostViewMedia? postViewMedia})? navigateToPost; - - final bool? indicateRead; - - const ThumbnailPreview({ - super.key, - required this.postViewMedia, - required this.navigateToPost, - this.indicateRead, - }); - - @override - Widget build(BuildContext context) { - final state = context.read().state; - final isUserLoggedIn = context.read().state.isLoggedIn; - - final indicateRead = this.indicateRead ?? state.dimReadPosts; - final hideNsfwPreviews = state.hideNsfwPreviews; - final markPostReadOnMediaView = state.markPostReadOnMediaView; - - return ExcludeSemantics( - child: Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4), - child: MediaView( - media: postViewMedia.media.first, - postId: postViewMedia.postView.post.id, - showFullHeightImages: false, - hideNsfwPreviews: hideNsfwPreviews, - markPostReadOnMediaView: markPostReadOnMediaView, - viewMode: ViewMode.compact, - isUserLoggedIn: isUserLoggedIn, - navigateToPost: navigateToPost, - read: indicateRead && postViewMedia.postView.read, - ), - ), - Padding( - padding: const EdgeInsets.only(right: 6, bottom: 0), - child: TypeBadge( - mediaType: postViewMedia.media.firstOrNull?.mediaType ?? MediaType.text, - dim: indicateRead && postViewMedia.postView.read, - ), - ), - ], - ), - ); - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ed1a2e3aa..eca02a329 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -709,6 +709,10 @@ "@deleteUserLabelConfirmation": { "description": "Confirmation message for deleting a label" }, + "deleted": "Deleted", + "@deleted": { + "description": "Status to indicate that an item has been deleted" + }, "deletedByCreator": "deleted by creator", "@deletedByCreator": { "description": "Placeholder text for a comment deleted by the creator. Be sure to keep this lowercase." @@ -1293,6 +1297,10 @@ "@lockPost": { "description": "Action for locking a post (moderator action)" }, + "locked": "Locked", + "@locked": { + "description": "Status to indicate that an item has been locked" + }, "lockedPost": "Locked Post", "@lockedPost": { "description": "Short decription for moderator action to lock a post" @@ -1681,6 +1689,10 @@ "@pinToCommunity": { "description": "Setting for pinning a post to a community (moderator action)" }, + "pinned": "Pinned", + "@pinned": { + "description": "Status to indicate that an item has been pinned" + }, "placeholderText": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "@placeholderText": { "description": "Placeholder text for any previews. This comes from https://www.lipsum.com/" @@ -1895,6 +1907,10 @@ "@removePost": { "description": "Action to remove a post (moderator action)" }, + "removed": "Removed", + "@removed": { + "description": "Status to indicate that an item has been removed" + }, "removedComment": "Removed Comment", "@removedComment": { "description": "Short decription for moderator action to remove a comment" diff --git a/lib/post/enums/post_status.dart b/lib/post/enums/post_status.dart new file mode 100644 index 000000000..0874c8410 --- /dev/null +++ b/lib/post/enums/post_status.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/utils/global_context.dart'; + +enum PostStatus { + hidden(icon: Icons.visibility_off_rounded, size: 16.0), + locked(icon: Icons.lock_rounded, size: 15.0), + saved(icon: Icons.star_rounded, size: 17.0), + pinned(icon: Icons.push_pin_rounded, size: 15.0, color: Colors.green), + deleted(icon: Icons.delete_rounded, size: 16.0, color: Colors.red), + removed(icon: Icons.delete_forever_rounded, size: 16.0, color: Colors.red); + + final IconData icon; + + final double size; + + final Color? color; + + double getScaledSize(double textScaleFactor) => size * textScaleFactor; + + Color getColor(BuildContext context) { + switch (this) { + case PostStatus.hidden: + return context.read().state.hideColor.color; + case PostStatus.locked: + return context.read().state.upvoteColor.color; + case PostStatus.saved: + return context.read().state.saveColor.color; + case PostStatus.pinned: + return color!; + case PostStatus.deleted: + return color!; + case PostStatus.removed: + return color!; + } + } + + String getLabel() { + final l10n = GlobalContext.l10n; + + switch (this) { + case PostStatus.hidden: + return l10n.hidden; + case PostStatus.locked: + return l10n.locked; + case PostStatus.saved: + return l10n.saved; + case PostStatus.pinned: + return l10n.pinned; + case PostStatus.deleted: + return l10n.deleted; + case PostStatus.removed: + return l10n.removed; + } + } + + const PostStatus({ + required this.icon, + required this.size, + this.color, + }); +} diff --git a/lib/post/widgets/post_card_title.dart b/lib/post/widgets/post_card_title.dart new file mode 100644 index 000000000..d9d61f124 --- /dev/null +++ b/lib/post/widgets/post_card_title.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:html_unescape/html_unescape_small.dart'; + +import 'package:thunder/core/enums/font_scale.dart'; +import 'package:thunder/post/widgets/post_status_icon.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; + +/// Creates the title of a post card. This includes the post title and any status icons. +class PostCardTitle extends StatelessWidget { + /// The title of the post. If there are any escaped characters, they will be unescaped. + final String title; + + /// The post status to indicate whether the post is hidden. + final bool hidden; + + /// The post status to indicate whether the post is locked. + final bool locked; + + /// The post status to indicate whether the post is saved. + final bool saved; + + /// The post status to indicate whether the post is pinned. + final bool pinned; + + /// The post status to indicate whether the post is deleted. + final bool deleted; + + /// The post status to indicate whether the post is removed. + final bool removed; + + /// Determines whether the title should be dimmed or not. This is usually to indicate when a post has been read. + final bool dim; + + const PostCardTitle({ + super.key, + required this.title, + this.hidden = false, + this.locked = false, + this.saved = false, + this.pinned = false, + this.deleted = false, + this.removed = false, + this.dim = false, + }); + + static final _html = HtmlUnescape(); + + Color? _getDimmedColor(Color? color) => color?.withValues(alpha: 0.55); + + Color? _getTitleColor(ThemeData theme) { + if (pinned) return dim ? _getDimmedColor(Colors.green) : Colors.green; + if (dim) return _getDimmedColor(theme.textTheme.bodyMedium?.color); + + return null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final textStyle = theme.textTheme.bodyMedium; + final fontSize = textStyle?.fontSize ?? 14.0; + + final textScaleFactor = context.select((ThunderBloc bloc) => bloc.state.titleFontSizeScale.textScaleFactor); + + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: PostStatusIcon( + hidden: hidden, + locked: locked, + saved: saved, + pinned: pinned, + deleted: deleted, + removed: removed, + dim: dim, + ), + ), + TextSpan( + text: _html.convert(title), + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + fontSize: MediaQuery.textScalerOf(context).scale(fontSize * textScaleFactor), + color: _getTitleColor(theme), + ), + ), + ], + ), + textScaler: TextScaler.noScaling, + ); + } +} diff --git a/lib/post/widgets/post_status_icon.dart b/lib/post/widgets/post_status_icon.dart new file mode 100644 index 000000000..7b34e419e --- /dev/null +++ b/lib/post/widgets/post_status_icon.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/core/enums/font_scale.dart'; +import 'package:thunder/post/enums/post_status.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; + +/// Given a list of statuses, returns a list of icons representing the statuses. +class PostStatusIcon extends StatelessWidget { + final bool hidden; + final bool locked; + final bool saved; + final bool pinned; + final bool deleted; + final bool removed; + final bool dim; + + const PostStatusIcon({ + super.key, + this.hidden = false, + this.locked = false, + this.saved = false, + this.pinned = false, + this.deleted = false, + this.removed = false, + this.dim = false, + }); + + static Color getDimmedColor(Color color) => color.withValues(alpha: 0.55); + + Widget _buildStatusIcon(BuildContext context, PostStatus status, bool isActive, double textScaleFactor) { + if (!isActive) return const SizedBox.shrink(); + + final color = dim ? getDimmedColor(status.getColor(context)) : status.getColor(context); + + return Icon( + status.icon, + color: color, + size: status.getScaledSize(textScaleFactor), + semanticLabel: status.getLabel(), + ); + } + + @override + Widget build(BuildContext context) { + final textScaleFactor = context.select((ThunderBloc bloc) => bloc.state.titleFontSizeScale.textScaleFactor); + + final statusMap = { + PostStatus.hidden: hidden, + PostStatus.locked: locked, + PostStatus.saved: saved, + PostStatus.pinned: pinned, + PostStatus.deleted: deleted, + PostStatus.removed: removed, + }; + + final List statuses = statusMap.entries + .where((entry) => entry.value) + .map((entry) => _buildStatusIcon(context, entry.key, entry.value, textScaleFactor)) + .whereType() // Filter out any null widgets + .toList(); + + return Wrap( + spacing: 2.0, + children: [ + ...statuses, + if (statuses.isNotEmpty) const SizedBox(width: 3.5), + ], + ); + } +} diff --git a/lib/post/widgets/post_view.dart b/lib/post/widgets/post_view.dart index 3fd38c84e..c8985d9ba 100644 --- a/lib/post/widgets/post_view.dart +++ b/lib/post/widgets/post_view.dart @@ -20,7 +20,7 @@ import 'package:thunder/feed/bloc/feed_bloc.dart'; import 'package:thunder/post/enums/post_action.dart'; import 'package:thunder/post/widgets/general_post_action_bottom_sheet.dart'; import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; -import 'package:thunder/community/widgets/post_card_type_badge.dart'; +import 'package:thunder/shared/media/media_type_badge.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/enums/media_type.dart'; @@ -399,7 +399,7 @@ class _PostSubviewState extends State with SingleTickerProviderStat ), Padding( padding: const EdgeInsets.only(right: 6, bottom: 0), - child: TypeBadge( + child: MediaTypeBadge( mediaType: postViewMedia.media.firstOrNull?.mediaType ?? MediaType.text, dim: false, ), diff --git a/lib/shared/media/compact_thumbnail_preview.dart b/lib/shared/media/compact_thumbnail_preview.dart new file mode 100644 index 000000000..34a5b3760 --- /dev/null +++ b/lib/shared/media/compact_thumbnail_preview.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/shared/media/media_type_badge.dart'; +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/enums/view_mode.dart'; +import 'package:thunder/core/models/media.dart'; +import 'package:thunder/shared/media/media_view.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; + +/// Displays a compact thumbnail preview for a post card. +class CompactThumbnailPreview extends StatelessWidget { + /// The media to display in the thumbnail + final Media media; + + /// Whether or not to dim the thumbnail. This is used when a post has been read. + /// This value can be overridden for special cases (e.g., viewing user account) + final bool dim; + + /// The callback function to navigate to the post + final void Function()? navigateToPost; + + const CompactThumbnailPreview({ + super.key, + required this.media, + this.dim = false, + this.navigateToPost, + }); + + @override + Widget build(BuildContext context) { + final hideNsfwPreviews = context.select((ThunderBloc bloc) => bloc.state.hideNsfwPreviews); + final markPostReadOnMediaView = context.select((ThunderBloc bloc) => bloc.state.markPostReadOnMediaView); + + final isUserLoggedIn = context.select((AuthBloc bloc) => bloc.state.isLoggedIn); + + return ExcludeSemantics( + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), + child: MediaView( + media: media, + showFullHeightImages: false, + hideNsfwPreviews: hideNsfwPreviews, + markPostReadOnMediaView: markPostReadOnMediaView, + viewMode: ViewMode.compact, + isUserLoggedIn: isUserLoggedIn, + navigateToPost: navigateToPost, + read: dim, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: MediaTypeBadge(mediaType: media.mediaType, dim: dim), + ), + ], + ), + ); + } +} diff --git a/lib/shared/media/media_type_badge.dart b/lib/shared/media/media_type_badge.dart new file mode 100644 index 000000000..80df3418a --- /dev/null +++ b/lib/shared/media/media_type_badge.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/core/enums/media_type.dart'; +import 'package:thunder/core/theme/bloc/theme_bloc.dart'; + +/// Base representation of a media type badge. Holds the icon and color. +class MediaTypeBadgeItem { + /// The icon associated with the media type + final IconData icon; + + /// The size of the icon + final double size; + + /// The color associated with the media type + final Color color; + + const MediaTypeBadgeItem({ + required this.color, + required this.icon, + required this.size, + }); +} + +class MediaTypeBadge extends StatelessWidget { + /// Determines whether the badge should be dimmed or not. This is usually to indicate when a post has been read. + final bool dim; + + /// The media type of the badge. This is used to determine the badge color and icon. + final MediaType mediaType; + + // Static constants for border radius + static const borderRadius = BorderRadius.only( + topLeft: Radius.circular(15), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(12), + topRight: Radius.circular(4), + ); + + static const innerBorderRadius = BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(12), + topRight: Radius.circular(4), + ); + + // Static map of media type definitions + static const Map mediaTypeBadgeItems = { + MediaType.text: MediaTypeBadgeItem( + color: Colors.green, + icon: Icons.wysiwyg_rounded, + size: 17, + ), + MediaType.link: MediaTypeBadgeItem( + color: Colors.blue, + icon: Icons.link_rounded, + size: 19, + ), + MediaType.image: MediaTypeBadgeItem( + color: Colors.red, + icon: Icons.image_outlined, + size: 17, + ), + MediaType.video: MediaTypeBadgeItem( + color: Colors.purple, + icon: Icons.play_arrow_rounded, + size: 17, + ), + }; + + const MediaTypeBadge({ + super.key, + required this.dim, + required this.mediaType, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final darkTheme = context.select((ThemeBloc bloc) => bloc.state.useDarkTheme); + + final mediaTypeItem = mediaTypeBadgeItems[mediaType]!; + + final backgroundColor = dim ? getBackgroundColor(theme.colorScheme.onSurface, theme.colorScheme.surface, darkTheme) : theme.colorScheme.surface; + final color = getMaterialColor(theme.colorScheme.primaryContainer, mediaTypeItem.color); + final iconColor = getIconColor(theme.colorScheme.onPrimaryContainer, mediaTypeItem.color); + + return SizedBox( + height: 28, + width: 28, + child: Material( + borderRadius: borderRadius, + color: backgroundColor, + child: Padding( + padding: const EdgeInsets.only(left: 2.5, top: 2.5), + child: Material( + borderRadius: innerBorderRadius, + color: color, + child: Icon(size: mediaTypeItem.size, mediaTypeItem.icon, color: iconColor), + ), + ), + ), + ); + } + + Color getBackgroundColor(Color foreground, Color background, bool isDarkTheme) { + return Color.alphaBlend(foreground.withValues(alpha: isDarkTheme ? 0.05 : 0.075), background); + } + + Color getMaterialColor(Color foreground, Color blendColor) { + return Color.alphaBlend(foreground.withValues(alpha: 0.6), blendColor).withValues(alpha: dim ? 0.55 : 1); + } + + Color getIconColor(Color foreground, Color blendColor) { + return Color.alphaBlend(foreground.withValues(alpha: 0.9), blendColor).withValues(alpha: dim ? 0.55 : 1); + } +}