diff --git a/.gitignore b/.gitignore index c8a9c19da..30dc4bccc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,10 @@ .idea .DS_Store -# Build artifacts +# Build Artifacts build release -# Flutter/Dart +# Flutter .dart_tool .flutter-plugins-dependencies - -# AI -.agents -.cursor -.claude -.github/instructions -.github/copilot-instructions.md \ No newline at end of file diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart index 30b3c5092..10bcbbf0c 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart @@ -5,12 +5,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_card/comment_card_replies_loader.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_card/comment_card_surface.dart'; import 'package:thunder/src/shared/gestures/swipe_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderMultiActionDismissible, ThunderSwipeAction; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a given comment. /// @@ -71,31 +71,19 @@ class CommentCard extends StatefulWidget { } class _CommentCardState extends State { - /// The internal comment. This is updated whenever the comment is updated. - late ThunderComment comment; - - /// Whether the comment is owned by the current user - late bool isOwnComment; - /// Whether we should display the comment's raw markdown source bool viewSource = false; /// Whether the comment is being dragged. bool _dragged = false; - @override - void initState() { - super.initState(); - - comment = widget.comment; - isOwnComment = comment.creatorId == widget.account.userId; - } + bool get _isOwnComment => widget.comment.creatorId == widget.account.userId; /// Maps a [SwipeAction] to a [CommentAction] and performs the action /// /// If [resolve] is true, the [SwipeAction.reply] will be resolved to [SwipeAction.edit] if the comment is owned by the current user. Future _onAction(SwipeAction action, {bool resolve = true}) async { - final resolvedSwipeAction = (action == SwipeAction.reply && isOwnComment && resolve) ? SwipeAction.edit : action; + final resolvedSwipeAction = (action == SwipeAction.reply && _isOwnComment && resolve) ? SwipeAction.edit : action; final commentAction = switch (resolvedSwipeAction) { SwipeAction.upvote => CommentAction.vote, @@ -112,7 +100,7 @@ class _CommentCardState extends State { context, widget.account, commentAction, - comment, + widget.comment, { 'voteType': resolvedSwipeAction == SwipeAction.upvote ? 1 @@ -127,7 +115,6 @@ class _CommentCardState extends State { return; } else if (updatedComment != null) { widget.onCommentUpdated?.call(updatedComment); - setState(() => comment = updatedComment); } } @@ -136,15 +123,16 @@ class _CommentCardState extends State { showCommentActionBottomModalSheet( context, - comment, + widget.comment, isShowingSource: viewSource, onAction: ({commentAction, communityAction, userAction, comment}) async { if (comment != null) { widget.onCommentUpdated?.call(comment); - setState(() => this.comment = comment); } - if (commentAction == CommentAction.viewSource) return setState(() => viewSource = !viewSource); + if (commentAction == CommentAction.viewSource) { + return setState(() => viewSource = !viewSource); + } // TODO: Move these into the comment bottom sheet logic if (commentAction == CommentAction.reply || commentAction == CommentAction.edit) { @@ -168,11 +156,6 @@ class _CommentCardState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - final nestedCommentIndicatorStyle = context.select((cubit) => cubit.state.nestedCommentIndicatorStyle); - final nestedCommentIndicatorColor = context.select((cubit) => cubit.state.nestedCommentIndicatorColor); - final showCommentButtonActions = context.select((cubit) => cubit.state.showCommentButtonActions); final enableCommentGestures = context.select((cubit) => cubit.state.enableCommentGestures); final leftPrimaryCommentGesture = context.select((cubit) => cubit.state.leftPrimaryCommentGesture); @@ -186,44 +169,25 @@ class _CommentCardState extends State { final currentSwipeDirection = determineCommentSwipeDirection(!widget.account.anonymous, enableCommentGestures, leftActions, rightActions); - Widget child = Material( - color: widget.highlight ? theme.highlightColor : null, - child: Container( - decoration: _dragged ? null : CommentDepthIndicatorDecoration(context, level: widget.level, style: nestedCommentIndicatorStyle, scheme: nestedCommentIndicatorColor), - child: InkWell( - onTap: () => widget.onCollapse?.call(comment.id, !widget.collapsed), - onLongPress: _onLongPress, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommentContent( - account: widget.account, - level: widget.level, - comment: comment, - hidden: widget.collapsed, - viewSource: viewSource, - onViewSourceToggled: () => setState(() => viewSource = !viewSource), - ), - if (showCommentButtonActions && !widget.account.anonymous && !widget.collapsed) - Padding( - padding: const EdgeInsets.only(bottom: 4.0, top: 6.0, right: 4.0), - child: CommentCardButtonActions( - account: widget.account, - comment: comment, - isOwnComment: isOwnComment, - onAction: (action) => _onAction(action), - onBottomSheetOpen: _onLongPress, - ), - ), - ], - ), - ), - ), + Widget child = CommentCardSurface( + account: widget.account, + comment: widget.comment, + level: widget.level, + collapsed: widget.collapsed, + highlighted: widget.highlight, + dragged: _dragged, + viewSource: viewSource, + showActions: showCommentButtonActions && !widget.account.anonymous && !widget.collapsed, + isOwnComment: _isOwnComment, + onCollapse: () => widget.onCollapse?.call(widget.comment.id, !widget.collapsed), + onLongPress: _onLongPress, + onViewSourceToggled: () => setState(() => viewSource = !viewSource), + onAction: _onAction, ); if (currentSwipeDirection != DismissDirection.none) { child = ThunderMultiActionDismissible( - key: ObjectKey(comment.id), + key: ObjectKey(widget.comment.id), direction: currentSwipeDirection, leftActions: leftActions.map((action) => ThunderSwipeAction(value: action, icon: action.getIcon(), color: (context) => action.getColor(context))).toList(), rightActions: rightActions.map((action) => ThunderSwipeAction(value: action, icon: action.getIcon(), color: (context) => action.getColor(context))).toList(), @@ -235,7 +199,7 @@ class _CommentCardState extends State { }, onAction: (action) => _onAction(action.value), backgroundBuilder: (context, dismissDirection, progress, action) => CommentCardBackground( - swipeAction: action?.value == SwipeAction.reply && isOwnComment ? SwipeAction.edit : action?.value, + swipeAction: action?.value == SwipeAction.reply && _isOwnComment ? SwipeAction.edit : action?.value, dismissThreshold: progress, firstActionThreshold: actionThresholds.first, dismissDirection: dismissDirection, @@ -252,18 +216,13 @@ class _CommentCardState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ child, - if (widget.replies == 0 && comment.childCount! > 0 && !widget.hideReplyCount) - AnimatedCrossFade( - duration: Duration(milliseconds: 350), - sizeCurve: Curves.easeInOutCubicEmphasized, - firstChild: SizedBox(width: MediaQuery.sizeOf(context).width), - secondChild: AdditionalCommentCard( - depth: widget.level, - replies: comment.childCount!, - onTap: () => context.read().add(GetPostCommentsEvent(commentParentId: comment.id)), - ), - crossFadeState: widget.collapsed ? CrossFadeState.showFirst : CrossFadeState.showSecond, - ) + CommentCardRepliesLoader( + comment: widget.comment, + level: widget.level, + replies: widget.replies, + collapsed: widget.collapsed, + hideReplyCount: widget.hideReplyCount, + ), ], ), crossFadeState: widget.hidden ? CrossFadeState.showFirst : CrossFadeState.showSecond, diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_replies_loader.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_replies_loader.dart new file mode 100644 index 000000000..2607d4e34 --- /dev/null +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_replies_loader.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/post/post.dart'; + +/// Displays the "load replies" action for comments with additional children. +class CommentCardRepliesLoader extends StatelessWidget { + const CommentCardRepliesLoader({super.key, required this.comment, required this.level, required this.replies, required this.collapsed, required this.hideReplyCount}); + + /// Comment whose replies may be loaded. + final ThunderComment comment; + + /// Nesting depth used for the additional-replies card. + final int level; + + /// Number of currently loaded replies. + final int replies; + + /// Whether the comment is collapsed. + final bool collapsed; + + /// Whether the reply loader should be suppressed. + final bool hideReplyCount; + + @override + Widget build(BuildContext context) { + if (replies != 0 || (comment.childCount ?? 0) <= 0 || hideReplyCount) { + return const SizedBox.shrink(); + } + + return AnimatedCrossFade( + duration: const Duration(milliseconds: 350), + sizeCurve: Curves.easeInOutCubicEmphasized, + firstChild: SizedBox(width: MediaQuery.sizeOf(context).width), + secondChild: AdditionalCommentCard( + depth: level, + replies: comment.childCount!, + onTap: () => context.read().add(GetPostCommentRepliesEvent(commentParentId: comment.id)), + ), + crossFadeState: collapsed ? CrossFadeState.showFirst : CrossFadeState.showSecond, + ); + } +} diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_surface.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_surface.dart new file mode 100644 index 000000000..fa266667e --- /dev/null +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_surface.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Renders the visible comment body and optional inline action buttons. +class CommentCardSurface extends StatelessWidget { + const CommentCardSurface({ + super.key, + required this.account, + required this.comment, + required this.level, + required this.collapsed, + required this.highlighted, + required this.dragged, + required this.viewSource, + required this.showActions, + required this.isOwnComment, + required this.onCollapse, + required this.onLongPress, + required this.onViewSourceToggled, + required this.onAction, + }); + + /// Account used for comment display and actions. + final Account account; + + /// Comment displayed in this row. + final ThunderComment comment; + + /// Nesting depth used for comment indicators. + final int level; + + /// Whether the row content is collapsed. + final bool collapsed; + + /// Whether the row should use the highlighted background. + final bool highlighted; + + /// Whether the row is currently being swiped. + final bool dragged; + + /// Whether to render the raw markdown source. + final bool viewSource; + + /// Whether inline action buttons should be displayed. + final bool showActions; + + /// Whether the current user owns [comment]. + final bool isOwnComment; + + /// Called when the user taps the comment body. + final VoidCallback onCollapse; + + /// Called when the user opens the action sheet. + final VoidCallback onLongPress; + + /// Called when source rendering is toggled. + final VoidCallback onViewSourceToggled; + + /// Handles an inline or gesture action. + final Future Function(SwipeAction action) onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final nestedCommentIndicatorStyle = context.select((cubit) => cubit.state.nestedCommentIndicatorStyle); + final nestedCommentIndicatorColor = context.select((cubit) => cubit.state.nestedCommentIndicatorColor); + + return Material( + color: highlighted ? theme.highlightColor : null, + child: Container( + decoration: dragged ? null : CommentDepthIndicatorDecoration(context, level: level, style: nestedCommentIndicatorStyle, scheme: nestedCommentIndicatorColor), + child: InkWell( + onTap: onCollapse, + onLongPress: onLongPress, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommentContent( + account: account, + level: level, + comment: comment, + hidden: collapsed, + viewSource: viewSource, + onViewSourceToggled: onViewSourceToggled, + ), + if (showActions) + Padding( + padding: const EdgeInsets.only(bottom: 4.0, top: 6.0, right: 4.0), + child: CommentCardButtonActions( + account: account, + comment: comment, + isOwnComment: isOwnComment, + onAction: onAction, + onBottomSheetOpen: onLongPress, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart index a28ed4fe0..0b27c172d 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart @@ -81,6 +81,13 @@ class _CommentContentState extends State with SingleTickerProvid final _selectableRegionFocusNode = FocusNode(); + @override + void dispose() { + _controller.dispose(); + _selectableRegionFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/src/features/community/community.dart b/lib/src/features/community/community.dart index 4ed36ca26..e81c9ab4a 100644 --- a/lib/src/features/community/community.dart +++ b/lib/src/features/community/community.dart @@ -9,6 +9,7 @@ export 'presentation/widgets/community_header/community_header_actions.dart'; export 'presentation/widgets/community_information.dart'; export 'presentation/widgets/community_list_entry.dart'; export 'presentation/widgets/post_card.dart'; +export 'presentation/widgets/post_card_action_background.dart'; export 'presentation/widgets/post_card_actions.dart'; export 'presentation/widgets/post_card_metadata.dart'; export 'presentation/widgets/post_card_view_comfortable.dart'; diff --git a/lib/src/features/community/presentation/widgets/post_card.dart b/lib/src/features/community/presentation/widgets/post_card.dart index 23ea2441e..32883bd24 100644 --- a/lib/src/features/community/presentation/widgets/post_card.dart +++ b/lib/src/features/community/presentation/widgets/post_card.dart @@ -10,13 +10,13 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/gestures/swipe_utils.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/packages/ui/ui.dart' show ThunderMultiActionDismissible, ThunderSwipeAction, showSnackbar; +/// Interactive feed card for a post. class PostCard extends StatefulWidget { /// The associated post information to display in the card. final ThunderPost post; @@ -270,13 +270,8 @@ class _PostCardState extends State { } if (postAction == PostAction.hide) { - if (widget.onDismissHiddenPost != null) { - widget.onDismissHiddenPost!(post!.id); - } else if (hasFeedBloc) { - context.read().add(FeedDismissHiddenPostEvent(postId: post!.id)); - } else { - FeedActionScope.maybeOf(context)?.dismissHiddenPost(post!.id); - } + final dismissHiddenPost = widget.onDismissHiddenPost ?? FeedActionScope.maybeOf(context)?.dismissHiddenPost; + dismissHiddenPost?.call(post!.id); } if (userAction == UserAction.block) { @@ -345,58 +340,3 @@ class _PostCardState extends State { ); } } - -/// Determines the appropriate color and icon for the post background swipe action -class PostCardActionBackground extends StatelessWidget { - const PostCardActionBackground({ - super.key, - this.swipeAction, - required this.firstActionThreshold, - required this.dismissThreshold, - required this.read, - required this.hidden, - required this.dismissDirection, - }); - - /// The [SwipeAction] to be performed - final SwipeAction? swipeAction; - - /// The threshold at which the first action should be triggered - final double firstActionThreshold; - - /// The current threshold of the swipe action - final double dismissThreshold; - - /// Whether the post is read - final bool read; - - /// Whether the post is hidden - final bool hidden; - - /// The direction of the swipe action - final DismissDirection dismissDirection; - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final tabletMode = context.select((bloc) => bloc.state.tabletMode); - final leftPrimaryPostGesture = context.select((cubit) => cubit.state.leftPrimaryPostGesture); - final rightPrimaryPostGesture = context.select((cubit) => cubit.state.rightPrimaryPostGesture); - - final alignment = dismissDirection == DismissDirection.startToEnd ? Alignment.centerLeft : Alignment.centerRight; - final defaultColor = dismissDirection == DismissDirection.startToEnd ? leftPrimaryPostGesture.getColor(context) : rightPrimaryPostGesture.getColor(context); - - final backgroundColor = swipeAction != null ? swipeAction!.getColor(context) : defaultColor.withValues(alpha: dismissThreshold / firstActionThreshold); - final computedWidth = width * (tabletMode ? 0.5 : 1) * dismissThreshold; - - return AnimatedContainer( - alignment: alignment, - duration: const Duration(milliseconds: 200), - color: backgroundColor, - child: SizedBox( - width: computedWidth, - child: swipeAction != null ? Icon(swipeAction!.getIcon(read: read, hidden: hidden)) : const SizedBox.shrink(), - ), - ); - } -} diff --git a/lib/src/features/community/presentation/widgets/post_card_action_background.dart b/lib/src/features/community/presentation/widgets/post_card_action_background.dart new file mode 100644 index 000000000..eb7068c17 --- /dev/null +++ b/lib/src/features/community/presentation/widgets/post_card_action_background.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/settings/api.dart'; + +/// Background shown behind a post card while a swipe action is in progress. +class PostCardActionBackground extends StatelessWidget { + const PostCardActionBackground({ + super.key, + this.swipeAction, + required this.firstActionThreshold, + required this.dismissThreshold, + required this.read, + required this.hidden, + required this.dismissDirection, + }); + + /// The active swipe action, when the swipe has crossed an action threshold. + final SwipeAction? swipeAction; + + /// Swipe progress where the first configured action becomes active. + final double firstActionThreshold; + + /// Current swipe progress as a fraction of the card width. + final double dismissThreshold; + + /// Whether the post is currently read. + final bool read; + + /// Whether the post is currently hidden. + final bool hidden; + + /// Direction of the active swipe gesture. + final DismissDirection dismissDirection; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final tabletMode = context.select((bloc) => bloc.state.tabletMode); + final leftPrimaryPostGesture = context.select((cubit) => cubit.state.leftPrimaryPostGesture); + final rightPrimaryPostGesture = context.select((cubit) => cubit.state.rightPrimaryPostGesture); + + final alignment = dismissDirection == DismissDirection.startToEnd ? Alignment.centerLeft : Alignment.centerRight; + final defaultColor = dismissDirection == DismissDirection.startToEnd ? leftPrimaryPostGesture.getColor(context) : rightPrimaryPostGesture.getColor(context); + + final backgroundColor = swipeAction != null ? swipeAction!.getColor(context) : defaultColor.withValues(alpha: dismissThreshold / firstActionThreshold); + final computedWidth = width * (tabletMode ? 0.5 : 1) * dismissThreshold; + + return AnimatedContainer( + alignment: alignment, + duration: const Duration(milliseconds: 200), + color: backgroundColor, + child: SizedBox( + width: computedWidth, + child: swipeAction != null ? Icon(swipeAction!.getIcon(read: read, hidden: hidden)) : const SizedBox.shrink(), + ), + ); + } +} diff --git a/lib/src/features/feed/domain/enums/enums.dart b/lib/src/features/feed/domain/enums/enums.dart index 098fd3dd0..266661ea6 100644 --- a/lib/src/features/feed/domain/enums/enums.dart +++ b/lib/src/features/feed/domain/enums/enums.dart @@ -1,2 +1,3 @@ +export 'feed_type.dart'; export 'feed_type_subview.dart'; export 'fab_action.dart'; diff --git a/lib/src/features/feed/domain/enums/feed_type.dart b/lib/src/features/feed/domain/enums/feed_type.dart new file mode 100644 index 000000000..e12143768 --- /dev/null +++ b/lib/src/features/feed/domain/enums/feed_type.dart @@ -0,0 +1,14 @@ +/// The type of feed content shown on a feed page. +enum FeedType { + /// A feed scoped to a single community. + community, + + /// A feed scoped to a single user. + user, + + /// A general listing feed, such as subscribed, local, or all. + general, + + /// The current account's own posts and comments. + account, +} diff --git a/lib/src/features/feed/presentation/pages/feed_page.dart b/lib/src/features/feed/presentation/pages/feed_page.dart index ae437ced0..99201684b 100644 --- a/lib/src/features/feed/presentation/pages/feed_page.dart +++ b/lib/src/features/feed/presentation/pages/feed_page.dart @@ -5,28 +5,20 @@ import 'package:flutter/services.dart'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/wiring/state_factories.dart'; - import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/session/api.dart'; - -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/feed/api.dart'; -import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/packages/ui/ui.dart' show showSnackbar; -enum FeedType { community, user, general, account } - /// Creates a [FeedPage] which holds a list of posts for a given user, community, or custom feed. /// /// A [FeedType] must be provided which indicates the type of feed to display. @@ -181,6 +173,7 @@ class _FeedPageState extends State with AutomaticKeepAliveClientMixin< } } +/// View layer for a feed once the appropriate [FeedBloc] has been provided. class FeedView extends StatefulWidget { const FeedView({super.key, required this.actionController, this.scaffoldStateKey, this.feedType, this.isActive = false}); @@ -203,11 +196,11 @@ class _FeedViewState extends State { final ScrollController _scrollController = ScrollController(); final Object _actionBindingToken = Object(); - /// Indicates which "tab" is selected. This is used for user profiles, where we can switch between posts and comments - List selectedUserOption = [true, false]; + /// Indicates which user-profile subview is selected. + FeedTypeSubview selectedSubview = FeedTypeSubview.post; - /// List of post ids to queue for removal. The ids in this list allow us to remove posts in a staggered method - List queuedForRemoval = []; + /// Post ids queued for staggered dismissal animation. + final ValueNotifier> queuedForRemoval = ValueNotifier>({}); String? tagline; @@ -220,6 +213,9 @@ class _FeedViewState extends State { /// Cache the hideBottomBarOnScroll setting to avoid repeated bloc reads bool? _cachedHideBottomBarOnScroll; + /// Prevents repeated excessive API call warnings while the state flag remains true. + bool _hasShownExcessiveApiCallsWarning = false; + @override void initState() { super.initState(); @@ -268,7 +264,7 @@ class _FeedViewState extends State { void _onScroll() { // Fetches new posts when the user has scrolled past 70% list if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent * 0.7 && context.read().state.status != FeedStatus.fetching) { - context.read().add(FeedFetchedEvent(feedTypeSubview: selectedUserOption[0] ? FeedTypeSubview.post : FeedTypeSubview.comment)); + context.read().add(FeedPaginatedEvent(feedTypeSubview: selectedSubview)); } // Detect scroll direction for bottom nav bar visibility @@ -298,6 +294,7 @@ class _FeedViewState extends State { void dispose() { widget.actionController.unbind(_actionBindingToken); _scrollController.removeListener(_onScroll); + queuedForRemoval.dispose(); _scrollController.dispose(); BackButtonInterceptor.remove(_handleBack); super.dispose(); @@ -308,113 +305,71 @@ class _FeedViewState extends State { /// /// Once those posts are fully added, an event is triggered which filters those posts from the feed bloc state Future dismissRead() async { - final useCompactView = context.read().state.useCompactView; - - FeedBloc feedBloc = context.read(); - List posts = feedBloc.state.posts; - - if (posts.isNotEmpty) { - for (ThunderPost post in posts) { - if (post.read == true) { - setState(() => queuedForRemoval.add(post.id)); - await Future.delayed(Duration(milliseconds: useCompactView ? 60 : 100)); - } - } - - await Future.delayed(const Duration(milliseconds: 500)); - - feedBloc.add(FeedHidePostsFromViewEvent(postIds: List.from(queuedForRemoval))); - setState(() => queuedForRemoval.clear()); - } + await _dismissMatchingPosts((post) => post.read == true); } + /// Animates blocked users or communities out of the feed before removal. Future dismissBlockedUsersAndCommunities(int? userId, int? communityId) async { - final useCompactView = context.read().state.useCompactView; - - FeedBloc feedBloc = context.read(); - List posts = feedBloc.state.posts; - - if (posts.isNotEmpty) { - for (ThunderPost post in posts) { - if (post.creator?.id == userId || post.community?.id == communityId) { - setState(() => queuedForRemoval.add(post.id)); - await Future.delayed(Duration(milliseconds: useCompactView ? 60 : 100)); - } - } - - await Future.delayed(const Duration(milliseconds: 500)); - - feedBloc.add(FeedHidePostsFromViewEvent(postIds: List.from(queuedForRemoval))); - setState(() => queuedForRemoval.clear()); - } + await _dismissMatchingPosts((post) => post.creator?.id == userId || post.community?.id == communityId); } + /// Animates a hidden post out of the feed before removing it from bloc state. Future dismissHiddenPost(int postId) async { + await _dismissMatchingPosts((post) => post.id == postId); + } + + /// Animates matching posts out of the list, then removes them from the feed state. + Future _dismissMatchingPosts(bool Function(ThunderPost post) shouldDismiss) async { final useCompactView = context.read().state.useCompactView; + final feedBloc = context.read(); + final postIds = feedBloc.state.posts.where(shouldDismiss).map((post) => post.id).toList(); - FeedBloc feedBloc = context.read(); - List posts = feedBloc.state.posts; + if (postIds.isEmpty) return; - if (posts.isNotEmpty) { - for (ThunderPost post in posts) { - if (post.id == postId) { - setState(() => queuedForRemoval.add(post.id)); - await Future.delayed(Duration(milliseconds: useCompactView ? 60 : 100)); - } - } + for (final postId in postIds) { + queuedForRemoval.value = {...queuedForRemoval.value, postId}; + await Future.delayed(Duration(milliseconds: useCompactView ? 60 : 100)); + } - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); - feedBloc.add(FeedHidePostsFromViewEvent(postIds: List.from(queuedForRemoval))); - setState(() => queuedForRemoval.clear()); - } + feedBloc.add(FeedHidePostsFromViewEvent(postIds: postIds)); + queuedForRemoval.value = queuedForRemoval.value.where((postId) => !postIds.contains(postId)).toSet(); } @override Widget build(BuildContext context) { final l10n = GlobalContext.l10n; - final tabletMode = context.select((bloc) => bloc.state.tabletMode); - final markPostReadOnScroll = context.select((cubit) => cubit.state.markPostReadOnScroll); - final hideTopBarOnScroll = context.select((bloc) => bloc.state.hideTopBarOnScroll); - final isFabOpen = context.select((cubit) => cubit.state.isFeedFabOpen); - final enableFeedsFab = context.select((cubit) => cubit.state.enableFeedsFab); - return Scaffold( body: SafeArea( top: false, - child: BlocConsumer( - listenWhen: (previous, current) { - if (current.excessiveApiCalls) { + child: BlocListener( + listenWhen: _shouldHandleFeedSideEffect, + listener: (context, state) { + if (!state.excessiveApiCalls) { + _hasShownExcessiveApiCallsWarning = false; + } + + if (state.excessiveApiCalls && !_hasShownExcessiveApiCallsWarning) { + _hasShownExcessiveApiCallsWarning = true; showSnackbar( l10n.excessiveApiCallsWarning, trailingIcon: Icons.settings_rounded, trailingAction: () => navigateToSettingPage(context, LocalSettings.settingsPageFilters, settingToHighlight: LocalSettings.keywordFilters), ); } - return true; - }, - buildWhen: (previous, current) => - previous.status != current.status || - previous.posts != current.posts || - previous.comments != current.comments || - previous.hasReachedPostsEnd != current.hasReachedPostsEnd || - previous.hasReachedCommentsEnd != current.hasReachedCommentsEnd || - previous.feedType != current.feedType || - previous.community != current.community || - previous.communityInstance != current.communityInstance || - previous.communityModerators != current.communityModerators || - previous.user != current.user || - previous.userModerates != current.userModerates, - listener: (context, state) { + // Continue to fetch more items as long as the device view is not scrollable. // This is to avoid cases where more items cannot be fetched because the conditions are not met - if (state.status == FeedStatus.success && ((selectedUserOption[0] && state.hasReachedPostsEnd == false) || (selectedUserOption[1] && state.hasReachedCommentsEnd == false))) { + if (state.status == FeedStatus.success && + ((selectedSubview == FeedTypeSubview.post && state.hasReachedPostsEnd == false) || (selectedSubview == FeedTypeSubview.comment && state.hasReachedCommentsEnd == false))) { Future.delayed(const Duration(milliseconds: 1000), () { if (!mounted) return; + if (!_scrollController.hasClients) return; bool isScrollable = _scrollController.position.maxScrollExtent > _scrollController.position.viewportDimension; if (!isScrollable) { - context.read().add(const FeedFetchedEvent()); + context.read().add(FeedPaginatedEvent(feedTypeSubview: selectedSubview)); } }); } @@ -424,146 +379,47 @@ class _FeedViewState extends State { context.read().add(FeedClearMessageEvent()); // Clear the message so that it does not spam } }, - builder: (context, state) { - final theme = Theme.of(context); - List posts = state.posts; - List comments = state.comments; - - return RefreshIndicator( - onRefresh: () async { - HapticFeedback.mediumImpact(); - triggerRefresh(context); - }, - edgeOffset: MediaQuery.of(context).padding.top + APP_BAR_HEIGHT, // This offset is placed to allow the correct positioning of the refresh indicator - child: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - widget.feedType == FeedType.account - ? AccountPageAppBar(scrollController: _scrollController) - : FeedPageAppBar(scrollController: _scrollController, scaffoldStateKey: widget.scaffoldStateKey), - // Display loading indicator until the feed is fetched - if (state.status == FeedStatus.initial) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ), - if (state.status == FeedStatus.failureLoadingCommunity || state.status == FeedStatus.failureLoadingUser) - SliverToBoxAdapter( - child: Container(), - ), - // Display tagline and list of posts once they are fetched - if (state.status != FeedStatus.initial && (state.status != FeedStatus.failureLoadingCommunity || state.status != FeedStatus.failureLoadingUser)) ...[ - SliverToBoxAdapter( - child: Visibility( - visible: state.feedType == FeedType.general && state.status != FeedStatus.initial, - child: TagLine(), - ), - ), - if (state.community != null && state.feedType == FeedType.community) - SliverToBoxAdapter( - child: CommunityHeader( - community: state.community!, - instance: state.communityInstance, - moderators: state.communityModerators, - condensed: false, - ), - ), - if (state.user != null && (state.feedType == FeedType.user || state.feedType == FeedType.account)) - SliverToBoxAdapter( - child: UserHeader( - user: state.user!, - moderates: state.userModerates, - feedType: selectedUserOption[0] ? FeedTypeSubview.post : FeedTypeSubview.comment, - onChangeFeedType: (feedType) { - setState(() { - selectedUserOption[0] = feedType == FeedTypeSubview.post; - selectedUserOption[1] = feedType == FeedTypeSubview.comment; - }); - }, - condensed: false, - ), - ), - selectedUserOption[1] - // Widget representing the list of user comments on the feed - ? FeedCommentCardList( - comments: comments, - tabletMode: tabletMode, - ) - : - // Widget representing the list of posts on the feed - FeedPostCardList( - posts: posts, - tabletMode: tabletMode, - markPostReadOnScroll: markPostReadOnScroll, - queuedForRemoval: queuedForRemoval, - dimReadPosts: state.feedType == FeedType.account ? false : null, - ), - // Widget representing the bottom of the feed (reached end or loading more posts indicators) - if (state.status != FeedStatus.failureLoadingCommunity && state.status != FeedStatus.failureLoadingUser) - SliverToBoxAdapter( - child: ((selectedUserOption[0] && state.hasReachedPostsEnd) || (selectedUserOption[1] && state.hasReachedCommentsEnd)) - ? const FeedReachedEnd() - : Container( - height: state.status == FeedStatus.initial ? MediaQuery.of(context).size.height * 0.5 : null, // Might have to adjust this to be more robust - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: const CircularProgressIndicator(), - ), - ), - ], - ], - ), - // Widget to host the feed FAB when navigating to new page - AnimatedOpacity( - opacity: isFabOpen ? 1.0 : 0.0, - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 250), - child: Stack( - children: [ - IgnorePointer( - child: Container( - color: theme.colorScheme.surface.withValues(alpha: 0.95), - )), - if (isFabOpen) - ModalBarrier( - color: null, - dismissible: true, - onDismiss: () => context.read().setFeedFabOpen(false), - ), - ], - ), - ), - if (Navigator.of(context).canPop() && - (state.communityId != null || state.communityName != null || state.userId != null || state.username != null) && - enableFeedsFab && - state.feedType != FeedType.account) - AnimatedOpacity( - opacity: enableFeedsFab ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeIn, - child: Container( - margin: const EdgeInsets.all(16), - child: FeedFAB(heroTag: state.communityName ?? state.username), - ), - ), - if (hideTopBarOnScroll) - Positioned( - child: Container( - height: MediaQuery.of(context).padding.top, - color: theme.colorScheme.surface, - ), - ) - ], - ), - ); - }, + child: RefreshIndicator( + onRefresh: () async { + HapticFeedback.mediumImpact(); + triggerRefresh(context); + }, + edgeOffset: MediaQuery.of(context).padding.top + APP_BAR_HEIGHT, + child: Stack( + children: [ + FeedScrollBody( + scrollController: _scrollController, + scaffoldStateKey: widget.scaffoldStateKey, + feedType: widget.feedType, + selectedSubview: selectedSubview, + queuedForRemoval: queuedForRemoval, + onChangeFeedType: (feedType) => setState(() => selectedSubview = feedType), + ), + const FeedFabOverlay(), + const FeedTopBarScrim(), + ], + ), + ), ), ), ); } + bool _shouldHandleFeedSideEffect(FeedState previous, FeedState current) { + final excessiveApiCallsStarted = !previous.excessiveApiCalls && current.excessiveApiCalls; + final hasNewFailureMessage = current.message != null && + (current.status == FeedStatus.failure || current.status == FeedStatus.failureLoadingCommunity || current.status == FeedStatus.failureLoadingUser) && + (previous.message != current.message || previous.status != current.status); + final contentChangedAfterSuccess = current.status == FeedStatus.success && + (previous.status != FeedStatus.success || + previous.posts.length != current.posts.length || + previous.comments.length != current.comments.length || + previous.hasReachedPostsEnd != current.hasReachedPostsEnd || + previous.hasReachedCommentsEnd != current.hasReachedCommentsEnd); + + return excessiveApiCallsStarted || hasNewFailureMessage || contentChangedAfterSuccess; + } + FutureOr _handleBack(bool stopDefaultButtonEvent, RouteInfo info) async { // If the feed is not active, we should not be intercepting the back button if (!widget.isActive) return false; @@ -610,77 +466,3 @@ class _FeedViewState extends State { return false; } } - -class FeedHeader extends StatelessWidget { - const FeedHeader({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return BlocSelector( - selector: (state) => ( - feedListType: state.feedListType, - postSortType: state.postSortType, - feedType: state.feedType, - community: state.community, - user: state.user, - ), - builder: (context, _) { - final state = context.read().state; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - getAppBarTitle(state), - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4.0), - Row( - children: [ - Icon(getSortIcon(state), size: 17), - const SizedBox(width: 4), - Text( - getSortName(state), - style: theme.textTheme.titleMedium, - ), - ], - ), - ], - ); - }, - ); - } -} - -class FeedReachedEnd extends StatelessWidget { - const FeedReachedEnd({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final theme = Theme.of(context); - final metadataFontSizeScale = context.read().state.metadataFontSizeScale; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - color: theme.dividerColor.withValues(alpha: 0.1), - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( - l10n.reachedTheBottom, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - textScaleFactor: metadataFontSizeScale.textScaleFactor, - ), - ), - const SizedBox(height: 160) - ], - ); - } -} diff --git a/lib/src/features/feed/presentation/state/feed_bloc.dart b/lib/src/features/feed/presentation/state/feed_bloc.dart index b33102224..9d353aa27 100644 --- a/lib/src/features/feed/presentation/state/feed_bloc.dart +++ b/lib/src/features/feed/presentation/state/feed_bloc.dart @@ -26,6 +26,7 @@ EventTransformer throttleDroppable(Duration duration) { }; } +/// Coordinates feed fetching, pagination, and local feed item mutations. class FeedBloc extends Bloc { final Account account; @@ -51,6 +52,12 @@ class FeedBloc extends Bloc { transformer: restartable(), ); + /// Handles fetching the next page for the currently loaded feed. + on( + _onFeedPaginated, + transformer: droppable(), + ); + /// Handles changing the sort type of the feed on( _onFeedChangePostSortType, @@ -100,13 +107,15 @@ class FeedBloc extends Bloc { /// Handles hiding posts from the feed. This will remove any posts from the feed for the given post ids Future _onFeedHidePostsFromView(FeedHidePostsFromViewEvent event, Emitter emit) async { - emit(state.copyWith(status: FeedStatus.fetching)); + if (event.postIds.isEmpty) return; final posts = hidePostsByIds( posts: state.posts, postIds: event.postIds.toSet(), ); + if (posts.length == state.posts.length) return; + emit(state.copyWith( status: FeedStatus.success, posts: posts, @@ -126,7 +135,6 @@ class FeedBloc extends Bloc { /// Handles post related actions on a given item within the feed Future _onFeedItemActioned(FeedItemActionedEvent event, Emitter emit) async { assert(!(event.post == null && event.postId == null && event.postIds == null)); - emit(state.copyWith(status: FeedStatus.fetching)); switch (event.postAction) { case PostAction.vote: @@ -210,7 +218,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.read(post.id, value); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); @@ -249,7 +257,7 @@ class FeedBloc extends Bloc { List failed = await postRepository.readMultiple(postIds, value); if (failed.isEmpty) { - return emit(state.copyWith(status: FeedStatus.success)); + return; } // Restore the original post contents if not successful @@ -289,7 +297,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.hide(post.id, value); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); @@ -321,7 +329,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.delete(post.id, value); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); @@ -346,7 +354,7 @@ class FeedBloc extends Bloc { try { await postRepository.report(post.id, value); - return emit(state.copyWith(status: FeedStatus.success)); + return; } catch (e) { return emit(state.copyWith(status: FeedStatus.failure)); } @@ -371,7 +379,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.lock(post.id, value); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); @@ -403,7 +411,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.pinCommunity(post.id, value); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); @@ -437,7 +445,7 @@ class FeedBloc extends Bloc { emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.remove(post.id, remove, reason); - if (success) return emit(state.copyWith(status: FeedStatus.success)); + if (success) return; // Restore the original post contents if not successful List restoredPosts = List.from(state.posts); @@ -459,19 +467,30 @@ class FeedBloc extends Bloc { Future _onFeedItemUpdated(FeedItemUpdatedEvent event, Emitter emit) async { // TODO: Add support for updating comments (for user profile) List updatedPosts = List.from(state.posts); + + bool foundPost = false; + for (final (index, post) in state.posts.indexed) { if (post.id == event.post.id) { final preserveMedia = event.post.media.isEmpty && post.media.isNotEmpty; - updatedPosts[index] = preserveMedia ? event.post.copyWith(media: post.media) : event.post; + final updatedPost = preserveMedia ? event.post.copyWith(media: post.media) : event.post; + if (updatedPost == post) return; + + updatedPosts[index] = updatedPost; + foundPost = true; + break; } } + if (!foundPost) return; + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } /// Handles updating information about a community Future _onFeedCommunityUpdated(FeedCommunityUpdatedEvent event, Emitter emit) async { - emit(state.copyWith(status: FeedStatus.fetching)); + if (event.community == state.community) return; + emit(state.copyWith(status: FeedStatus.success, community: event.community)); } @@ -661,64 +680,27 @@ class FeedBloc extends Bloc { )); } - // If the feed is already being fetched but it is not a reset, then just wait - // If the cursor is null, then we are at the end of the feed so we don't need to fetch any more - if (state.status == FeedStatus.fetching || state.cursor == null) return; - - // Handle fetching the next page of the feed - emit(state.copyWith(status: FeedStatus.fetching)); - - List posts = List.from(state.posts); - List comments = List.from(state.comments); - - FeedResult feedItemResult = await fetchFeedItems( - account: account, - cursor: state.cursor, - feedListType: state.feedListType, - postSortType: state.postSortType, - communityId: state.communityId, - communityName: state.communityName, - userId: state.userId, - username: state.username, - feedTypeSubview: event.feedTypeSubview, - showHidden: state.showHidden, - showSaved: state.showSaved, - ); - - // Extract information from the response - List newPosts = feedItemResult.posts; - List newComments = feedItemResult.comments; - bool hasReachedPostsEnd = feedItemResult.hasReachedPostsEnd; - bool hasReachedCommentsEnd = feedItemResult.hasReachedCommentsEnd; - String? cursor = feedItemResult.cursor; - - Set newInsertedPostIds = Set.from(state.insertedPostIds); - List filteredPosts = []; - - // Ensure we don't add existing posts to view - for (ThunderPost post in newPosts) { - int id = post.id; - if (!newInsertedPostIds.contains(id)) { - newInsertedPostIds.add(id); - filteredPosts.add(post); - } - } - - posts.addAll(filteredPosts); - comments.addAll(newComments); - + return _fetchNextFeedPage(event.feedTypeSubview, emit); + } catch (e) { + debugPrint('Error fetching feed: $e'); + final message = getExceptionErrorMessage(e); return emit(state.copyWith( - status: FeedStatus.success, - insertedPostIds: newInsertedPostIds.toList(), - posts: posts, - comments: comments, - hasReachedPostsEnd: hasReachedPostsEnd, - hasReachedCommentsEnd: hasReachedCommentsEnd, - cursor: cursor, - errorReason: null, + status: FeedStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), )); + } + } + + /// Fetches the next page for the current feed state. + Future _onFeedPaginated(FeedPaginatedEvent event, Emitter emit) async { + try { + return _fetchNextFeedPage(event.feedTypeSubview, emit); } catch (e) { - debugPrint('Error fetching feed: $e'); + debugPrint('Error fetching feed page: $e'); final message = getExceptionErrorMessage(e); return emit(state.copyWith( status: FeedStatus.failure, @@ -731,6 +713,54 @@ class FeedBloc extends Bloc { } } + Future _fetchNextFeedPage(FeedTypeSubview feedTypeSubview, Emitter emit) async { + // If the feed is already being fetched, just wait for that request to finish. + // If the cursor is null, then we are at the end of the feed. + if (state.status == FeedStatus.fetching || state.cursor == null) return; + + emit(state.copyWith(status: FeedStatus.fetching)); + + final posts = List.from(state.posts); + final comments = List.from(state.comments); + + final feedItemResult = await fetchFeedItems( + account: account, + cursor: state.cursor, + feedListType: state.feedListType, + postSortType: state.postSortType, + communityId: state.communityId, + communityName: state.communityName, + userId: state.userId, + username: state.username, + feedTypeSubview: feedTypeSubview, + showHidden: state.showHidden, + showSaved: state.showSaved, + ); + + final newInsertedPostIds = Set.from(state.insertedPostIds); + final filteredPosts = []; + + for (final post in feedItemResult.posts) { + if (newInsertedPostIds.add(post.id)) { + filteredPosts.add(post); + } + } + + posts.addAll(filteredPosts); + comments.addAll(feedItemResult.comments); + + return emit(state.copyWith( + status: FeedStatus.success, + insertedPostIds: newInsertedPostIds.toList(), + posts: posts, + comments: comments, + hasReachedPostsEnd: feedItemResult.hasReachedPostsEnd, + hasReachedCommentsEnd: feedItemResult.hasReachedCommentsEnd, + cursor: feedItemResult.cursor, + errorReason: null, + )); + } + /// This function is used to create a post. We can pass in a communityId directly to determine the community to post to. Future _onCreatePost(CreatePostEvent event, Emitter emit) async { emit(state.copyWith(status: FeedStatus.fetching)); diff --git a/lib/src/features/feed/presentation/state/feed_event.dart b/lib/src/features/feed/presentation/state/feed_event.dart index 0fea50c88..7da3b9d48 100644 --- a/lib/src/features/feed/presentation/state/feed_event.dart +++ b/lib/src/features/feed/presentation/state/feed_event.dart @@ -160,6 +160,20 @@ final class FeedFetchedEvent extends FeedEvent { List get props => [feedType, feedTypeSubview, feedListType, postSortType, communityId, communityName, userId, username, reset, showHidden, showSaved]; } +/// Requests the next page for the currently loaded feed. +/// +/// This is separate from [FeedFetchedEvent] so page fetches can be droppable +/// while reset/refresh fetches remain restartable. +final class FeedPaginatedEvent extends FeedEvent { + /// The active user-profile subview to paginate. + final FeedTypeSubview feedTypeSubview; + + const FeedPaginatedEvent({this.feedTypeSubview = FeedTypeSubview.post}); + + @override + List get props => [feedTypeSubview]; +} + final class FeedChangePostSortTypeEvent extends FeedEvent { final PostSortType postSortType; diff --git a/lib/src/features/feed/presentation/state/feed_state.dart b/lib/src/features/feed/presentation/state/feed_state.dart index b1350db93..781d97e6d 100644 --- a/lib/src/features/feed/presentation/state/feed_state.dart +++ b/lib/src/features/feed/presentation/state/feed_state.dart @@ -4,6 +4,7 @@ const _feedStateUnset = Object(); enum FeedStatus { initial, fetching, success, failure, failureLoadingCommunity, failureLoadingUser } +/// Immutable state for feed metadata, visible items, and pagination flags. final class FeedState extends Equatable { const FeedState({ this.status = FeedStatus.initial, diff --git a/lib/src/features/feed/presentation/widgets/feed_app_bar_community_actions.dart b/lib/src/features/feed/presentation/widgets/feed_app_bar_community_actions.dart new file mode 100644 index 000000000..e871a1c79 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_app_bar_community_actions.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; + +/// App bar actions shown on community feeds. +class FeedAppBarCommunityActions extends StatelessWidget { + const FeedAppBarCommunityActions({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + final feedBloc = context.read(); + final postSortType = feedBloc.state.postSortType; + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), + onPressed: () { + HapticFeedback.mediumImpact(); + triggerRefresh(context); + }, + ), + IconButton( + icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), + onPressed: () { + HapticFeedback.mediumImpact(); + showModalBottomSheet( + showDragHandle: true, + context: context, + isScrollControlled: true, + builder: (builderContext) => SortPicker( + account: feedBloc.account, + title: l10n.sortOptions, + onSelect: (selected) async => context.read().add(FeedChangePostSortTypeEvent(selected.payload)), + previouslySelected: postSortType, + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_app_bar_general_actions.dart b/lib/src/features/feed/presentation/widgets/feed_app_bar_general_actions.dart new file mode 100644 index 000000000..5151020c4 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_app_bar_general_actions.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; + +/// App bar actions shown on general listing feeds. +class FeedAppBarGeneralActions extends StatelessWidget { + const FeedAppBarGeneralActions({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final feedBloc = context.read(); + + return Row( + children: [ + IconButton( + onPressed: () { + HapticFeedback.mediumImpact(); + triggerRefresh(context); + }, + icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), + ), + IconButton( + icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), + onPressed: () { + HapticFeedback.mediumImpact(); + showModalBottomSheet( + showDragHandle: true, + context: context, + isScrollControlled: true, + builder: (builderContext) => SortPicker( + account: feedBloc.account, + title: l10n.sortOptions, + onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), + previouslySelected: feedBloc.state.postSortType, + ), + ); + }, + ), + Semantics( + label: l10n.menu, + child: PopupMenuButton( + onOpened: () => HapticFeedback.mediumImpact(), + itemBuilder: (context) => [ + ThunderPopupMenuItem( + onTap: () async { + HapticFeedback.mediumImpact(); + await navigateToModlogPage(context, subtitle: context.read().state.account.instance); + }, + icon: Icons.shield_rounded, + title: l10n.modlog, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_app_bar_title.dart b/lib/src/features/feed/presentation/widgets/feed_app_bar_title.dart new file mode 100644 index 000000000..5e77a691e --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_app_bar_title.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Title area for the feed app bar, including feed name and active sort. +class FeedAppBarTitle extends StatelessWidget { + const FeedAppBarTitle({super.key, this.visible = true}); + + /// Whether the title should be visible. + final bool visible; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocSelector( + selector: (state) => ( + feedListType: state.feedListType, + postSortType: state.postSortType, + feedType: state.feedType, + community: state.community, + user: state.user, + ), + builder: (context, _) { + final state = context.read().state; + + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: visible ? 1.0 : 0.0, + child: ListTile( + title: Text( + getAppBarTitle(state), + style: theme.textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + Icon(getSortIcon(state), size: 13.0), + const SizedBox(width: 4.0), + Text(getSortName(state)), + ], + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 0.0), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_app_bar_user_actions.dart b/lib/src/features/feed/presentation/widgets/feed_app_bar_user_actions.dart new file mode 100644 index 000000000..0f9f8942c --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_app_bar_user_actions.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; + +/// App bar actions shown on user feeds. +class FeedAppBarUserActions extends StatelessWidget { + const FeedAppBarUserActions({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final feedBloc = context.read(); + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), + onPressed: () { + HapticFeedback.mediumImpact(); + triggerRefresh(context); + }, + ), + IconButton( + icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), + onPressed: () { + HapticFeedback.mediumImpact(); + showModalBottomSheet( + showDragHandle: true, + context: context, + isScrollControlled: true, + builder: (builderContext) => SortPicker( + account: feedBloc.account, + title: l10n.sortOptions, + onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), + previouslySelected: feedBloc.state.postSortType, + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_bottom_sliver.dart b/lib/src/features/feed/presentation/widgets/feed_bottom_sliver.dart new file mode 100644 index 000000000..fe4d05ada --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_bottom_sliver.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/feed/feed.dart'; + +/// Sliver that shows the feed footer, either an end marker or pagination spinner. +class FeedBottomSliver extends StatelessWidget { + const FeedBottomSliver({ + super.key, + required this.state, + required this.selectedSubview, + }); + + /// Current feed state containing end-of-feed flags. + final FeedState state; + + /// Selected feed subview whose end flag should be used. + final FeedTypeSubview selectedSubview; + + @override + Widget build(BuildContext context) { + final hasReachedEnd = selectedSubview == FeedTypeSubview.post ? state.hasReachedPostsEnd : state.hasReachedCommentsEnd; + + return SliverToBoxAdapter( + child: hasReachedEnd + ? const FeedReachedEnd() + : const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_content_sliver.dart b/lib/src/features/feed/presentation/widgets/feed_content_sliver.dart new file mode 100644 index 000000000..e9a602109 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_content_sliver.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/feed.dart'; + +/// Sliver that displays either feed posts or user-profile comments. +class FeedContentSliver extends StatelessWidget { + const FeedContentSliver({ + super.key, + required this.state, + required this.selectedSubview, + required this.queuedForRemoval, + }); + + /// Current feed state containing the visible posts or comments. + final FeedState state; + + /// Selected feed subview for user profiles. + final FeedTypeSubview selectedSubview; + + /// Post ids currently animating out of the post list. + final Set queuedForRemoval; + + @override + Widget build(BuildContext context) { + final tabletMode = context.select((bloc) => bloc.state.tabletMode); + final markPostReadOnScroll = context.select((cubit) => cubit.state.markPostReadOnScroll); + + if (selectedSubview == FeedTypeSubview.comment) { + return FeedCommentCardList( + comments: state.comments, + tabletMode: tabletMode, + ); + } + + return FeedPostCardList( + posts: state.posts, + tabletMode: tabletMode, + markPostReadOnScroll: markPostReadOnScroll, + queuedForRemoval: queuedForRemoval, + dimReadPosts: state.feedType == FeedType.account ? false : null, + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_drawer_button.dart b/lib/src/features/feed/presentation/widgets/feed_drawer_button.dart new file mode 100644 index 000000000..5cba3889f --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_drawer_button.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/shared/avatars/user_avatar.dart'; + +/// Leading app bar button that opens the drawer on root feeds or pops pushed feeds. +class FeedDrawerButton extends StatelessWidget { + const FeedDrawerButton({ + super.key, + required this.showProfilePicture, + required this.onTap, + required this.isRoot, + }); + + /// Whether the feed is the main feed with a subscriptions drawer. + final bool isRoot; + + /// Whether to display the current user's avatar instead of a menu icon. + final bool showProfilePicture; + + /// Callback invoked when the user taps the button. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (isRoot && showProfilePicture) return _buildProfilePictureButton(context); + return _buildIconButton(context); + } + + Widget _buildProfilePictureButton(BuildContext context) { + final state = context.read().state; + + return Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Semantics( + label: MaterialLocalizations.of(context).openAppDrawerTooltip, + child: Stack( + children: [ + if (state.user != null) Align(alignment: Alignment.center, child: UserAvatar(user: state.user!)), + Material( + color: Colors.transparent, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + ), + ), + ], + ), + ), + ); + } + + Widget _buildIconButton(BuildContext context) { + final IconData icon; + final String semanticLabel; + + if (isRoot) { + icon = Icons.menu; + semanticLabel = MaterialLocalizations.of(context).openAppDrawerTooltip; + } else { + icon = (!kIsWeb && Platform.isIOS) ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back_rounded; + semanticLabel = MaterialLocalizations.of(context).backButtonTooltip; + } + + return IconButton( + icon: Icon(icon, semanticLabel: semanticLabel), + onPressed: onTap, + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_fab.dart b/lib/src/features/feed/presentation/widgets/feed_fab.dart index f27bf9307..27f9f31ec 100644 --- a/lib/src/features/feed/presentation/widgets/feed_fab.dart +++ b/lib/src/features/feed/presentation/widgets/feed_fab.dart @@ -11,6 +11,7 @@ import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/feed/presentation/widgets/feed_fab_action_config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/shared/fabs/gesture_fab.dart'; @@ -18,6 +19,7 @@ import 'package:thunder/src/shared/fabs/gesture_fab.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +/// Floating action button menu for feed-level actions. class FeedFAB extends StatelessWidget { const FeedFAB({super.key, this.heroTag, this.actionController}); @@ -34,70 +36,30 @@ class FeedFAB extends StatelessWidget { final feedFabSinglePressAction = context.select((cubit) => cubit.state.feedFabSinglePressAction); final feedFabLongPressAction = context.select((cubit) => cubit.state.feedFabLongPressAction); final isFabSummoned = context.select((cubit) => cubit.state.isFeedFabSummoned); - final FeedState feedState = context.watch().state; - final ProfileState profileState = context.read().state; - - // A list of actions that are not supported through the general feed - List unsupportedGeneralFeedFabActions = []; - - // A list of actions that are not supported through the navigated community feed - List unsupportedNavigatedCommunityFeedFabActions = [ - FeedFabAction.subscriptions, - ]; - - // A list of actions that are not supported through the navigated user feed - List unsupportedNavigatedUserFeedFabActions = [ - FeedFabAction.subscriptions, - FeedFabAction.newPost, - FeedFabAction.dismissRead, - ]; - - FeedFabAction singlePressAction = feedFabSinglePressAction; - FeedFabAction longPressAction = feedFabLongPressAction; - - // Check to see if we are in the general feeds - bool isGeneralFeed = feedState.status != FeedStatus.initial && feedState.feedType == FeedType.general; - bool isCommunityFeed = feedState.status != FeedStatus.initial && feedState.feedType == FeedType.community; - bool isUserFeed = feedState.status != FeedStatus.initial && feedState.feedType == FeedType.user; - bool isNavigatedFeed = Navigator.canPop(context); - - bool isPostLocked = false; - - if (profileState.isLoggedIn && isCommunityFeed) { - final community = feedState.community; - - if (community!.postingRestrictedToMods && !profileState.moderates.any((c) => c.id == community.id)) { - isPostLocked = true; - } - } - - List disabledActions = []; - - if (isGeneralFeed) { - disabledActions = unsupportedGeneralFeedFabActions; - } else if (isCommunityFeed && isNavigatedFeed) { - disabledActions = unsupportedNavigatedCommunityFeedFabActions; - } else if (isUserFeed && isNavigatedFeed) { - disabledActions = unsupportedNavigatedUserFeedFabActions; - } - - // Check single-press action - if (isGeneralFeed && unsupportedGeneralFeedFabActions.contains(singlePressAction)) { - singlePressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } else if (isCommunityFeed && isNavigatedFeed && unsupportedNavigatedCommunityFeedFabActions.contains(singlePressAction)) { - singlePressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } else if (isUserFeed && unsupportedNavigatedUserFeedFabActions.contains(singlePressAction)) { - singlePressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } + final feedState = context.select( + (bloc) => ( + status: bloc.state.status, + feedType: bloc.state.feedType, + community: bloc.state.community, + ), + ); + final profileState = context.select moderates})>( + (bloc) => ( + isLoggedIn: bloc.state.isLoggedIn, + moderates: bloc.state.moderates, + ), + ); - // Check long-press action - if (isGeneralFeed && unsupportedGeneralFeedFabActions.contains(longPressAction)) { - longPressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } else if (isCommunityFeed && isNavigatedFeed && unsupportedNavigatedCommunityFeedFabActions.contains(longPressAction)) { - longPressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } else if (isUserFeed && unsupportedNavigatedUserFeedFabActions.contains(longPressAction)) { - longPressAction = FeedFabAction.openFab; // Default to open fab on unsupported actions - } + final config = resolveFeedFabActionConfig( + preferredSinglePressAction: feedFabSinglePressAction, + preferredLongPressAction: feedFabLongPressAction, + status: feedState.status, + feedType: feedState.feedType, + community: feedState.community, + isLoggedIn: profileState.isLoggedIn, + moderates: profileState.moderates, + isNavigatedFeed: Navigator.canPop(context), + ); return AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -113,16 +75,16 @@ class FeedFAB extends StatelessWidget { ? GestureFab( heroTag: heroTag, distance: 60, - fabBackgroundColor: (singlePressAction == FeedFabAction.newPost && isPostLocked) ? theme.colorScheme.errorContainer : null, + fabBackgroundColor: (config.singlePressAction == FeedFabAction.newPost && config.isPostLocked) ? theme.colorScheme.errorContainer : null, icon: Icon( - (singlePressAction == FeedFabAction.newPost && isPostLocked) ? Icons.lock : singlePressAction.icon, - semanticLabel: singlePressAction.title, + (config.singlePressAction == FeedFabAction.newPost && config.isPostLocked) ? Icons.lock : config.singlePressAction.icon, + semanticLabel: config.singlePressAction.title, size: 35, ), onPressed: () { HapticFeedback.lightImpact(); - switch (singlePressAction) { + switch (config.singlePressAction) { case FeedFabAction.openFab: triggerOpenFab(context); break; @@ -142,14 +104,14 @@ class FeedFAB extends StatelessWidget { triggerScrollToTop(context); break; case FeedFabAction.newPost: - triggerNewPost(context, isPostingLocked: isPostLocked); + triggerNewPost(context, isPostingLocked: config.isPostLocked); break; } }, onLongPress: () { HapticFeedback.mediumImpact(); - switch (longPressAction) { + switch (config.longPressAction) { case FeedFabAction.openFab: triggerOpenFab(context); break; @@ -169,12 +131,12 @@ class FeedFAB extends StatelessWidget { triggerScrollToTop(context); break; case FeedFabAction.newPost: - triggerNewPost(context, isPostingLocked: isPostLocked); + triggerNewPost(context, isPostingLocked: config.isPostLocked); break; } }, fabType: FabType.feed, - children: getEnabledActions(context, isPostingLocked: isPostLocked, disabledActions: disabledActions), + children: getEnabledActions(context, isPostingLocked: config.isPostLocked, disabledActions: config.disabledActions), ) : Stack( // This creates an invisible touch target to summon the FAB diff --git a/lib/src/features/feed/presentation/widgets/feed_fab_action_config.dart b/lib/src/features/feed/presentation/widgets/feed_fab_action_config.dart new file mode 100644 index 000000000..3a3316b9f --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_fab_action_config.dart @@ -0,0 +1,56 @@ +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Resolved action configuration for [FeedFAB]. +class FeedFabActionConfig { + const FeedFabActionConfig({ + required this.singlePressAction, + required this.longPressAction, + required this.disabledActions, + required this.isPostLocked, + }); + + /// Action executed by a normal FAB press. + final FeedFabAction singlePressAction; + + /// Action executed by a long FAB press. + final FeedFabAction longPressAction; + + /// Actions hidden from the expanded FAB menu. + final List disabledActions; + + /// Whether new-post creation is locked to community moderators. + final bool isPostLocked; +} + +/// Resolves feed FAB actions for the current route and account permissions. +FeedFabActionConfig resolveFeedFabActionConfig({ + required FeedFabAction preferredSinglePressAction, + required FeedFabAction preferredLongPressAction, + required FeedStatus status, + required FeedType? feedType, + required ThunderCommunity? community, + required bool isLoggedIn, + required List moderates, + required bool isNavigatedFeed, +}) { + final isGeneralFeed = status != FeedStatus.initial && feedType == FeedType.general; + final isCommunityFeed = status != FeedStatus.initial && feedType == FeedType.community; + final isUserFeed = status != FeedStatus.initial && feedType == FeedType.user; + + final disabledActions = switch ((isGeneralFeed, isCommunityFeed && isNavigatedFeed, isUserFeed && isNavigatedFeed)) { + (true, _, _) => const [], + (_, true, _) => const [FeedFabAction.subscriptions], + (_, _, true) => const [FeedFabAction.subscriptions, FeedFabAction.newPost, FeedFabAction.dismissRead], + _ => const [], + }; + + final isPostLocked = isLoggedIn && isCommunityFeed && community != null && community.postingRestrictedToMods && !moderates.any((moderatedCommunity) => moderatedCommunity.id == community.id); + + return FeedFabActionConfig( + singlePressAction: disabledActions.contains(preferredSinglePressAction) ? FeedFabAction.openFab : preferredSinglePressAction, + longPressAction: disabledActions.contains(preferredLongPressAction) ? FeedFabAction.openFab : preferredLongPressAction, + disabledActions: disabledActions, + isPostLocked: isPostLocked, + ); +} diff --git a/lib/src/features/feed/presentation/widgets/feed_fab_overlay.dart b/lib/src/features/feed/presentation/widgets/feed_fab_overlay.dart new file mode 100644 index 000000000..9182d6458 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_fab_overlay.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; +import 'package:thunder/src/features/feed/feed.dart'; + +/// Overlay that hosts the feed FAB barrier and the route-scoped feed FAB. +class FeedFabOverlay extends StatelessWidget { + const FeedFabOverlay({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final isFabOpen = context.select((cubit) => cubit.state.isFeedFabOpen); + final enableFeedsFab = context.select((cubit) => cubit.state.enableFeedsFab); + final feedChrome = context.select( + (bloc) => ( + feedType: bloc.state.feedType, + communityId: bloc.state.communityId, + communityName: bloc.state.communityName, + userId: bloc.state.userId, + username: bloc.state.username, + ), + ); + final showNavigatedFab = Navigator.of(context).canPop() && + (feedChrome.communityId != null || feedChrome.communityName != null || feedChrome.userId != null || feedChrome.username != null) && + enableFeedsFab && + feedChrome.feedType != FeedType.account; + + return Stack( + children: [ + AnimatedOpacity( + opacity: isFabOpen ? 1.0 : 0.0, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 250), + child: Stack( + children: [ + IgnorePointer( + child: Container( + color: theme.colorScheme.surface.withValues(alpha: 0.95), + ), + ), + if (isFabOpen) + ModalBarrier( + color: null, + dismissible: true, + onDismiss: () => context.read().setFeedFabOpen(false), + ), + ], + ), + ), + if (showNavigatedFab) + AnimatedOpacity( + opacity: enableFeedsFab ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeIn, + child: Container( + margin: const EdgeInsets.all(16.0), + child: FeedFAB(heroTag: feedChrome.communityName ?? feedChrome.username), + ), + ), + ], + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_header.dart b/lib/src/features/feed/presentation/widgets/feed_header.dart new file mode 100644 index 000000000..159774245 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_header.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Compact feed title and sort summary used by feed chrome. +class FeedHeader extends StatelessWidget { + const FeedHeader({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocSelector( + selector: (state) => ( + feedListType: state.feedListType, + postSortType: state.postSortType, + feedType: state.feedType, + community: state.community, + user: state.user, + ), + builder: (context, _) { + final state = context.read().state; + + return Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + getAppBarTitle(state), + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Row( + spacing: 4.0, + children: [ + Icon(getSortIcon(state), size: 17.0), + Text(getSortName(state), style: theme.textTheme.titleMedium), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_header_sliver.dart b/lib/src/features/feed/presentation/widgets/feed_header_sliver.dart new file mode 100644 index 000000000..7155c87f5 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_header_sliver.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Sliver that displays the optional tagline, community header, or user header for a feed. +class FeedHeaderSliver extends StatelessWidget { + const FeedHeaderSliver({ + super.key, + required this.state, + required this.selectedSubview, + required this.onChangeFeedType, + }); + + /// Current feed state used to choose and populate the visible header. + final FeedState state; + + /// Selected user-profile subview when the header belongs to a user feed. + final FeedTypeSubview selectedSubview; + + /// Called when the user switches between posts and comments in a user header. + final ValueChanged onChangeFeedType; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.feedType == FeedType.general) TagLine(), + if (state.community != null && state.feedType == FeedType.community) + CommunityHeader( + community: state.community!, + instance: state.communityInstance, + moderators: state.communityModerators, + condensed: false, + ), + if (state.user != null && (state.feedType == FeedType.user || state.feedType == FeedType.account)) + UserHeader( + user: state.user!, + moderates: state.userModerates, + feedType: selectedSubview, + onChangeFeedType: onChangeFeedType, + condensed: false, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart b/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart new file mode 100644 index 000000000..73e5f6127 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Sliver shown while a feed is performing its initial load. +class FeedInitialLoadingSliver extends StatelessWidget { + const FeedInitialLoadingSliver({super.key}); + + @override + Widget build(BuildContext context) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart index 4f9e64648..ec0e726d2 100644 --- a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart +++ b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart @@ -1,21 +1,12 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/foundation/config/config.dart'; -import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/src/shared/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem; /// Holds the app bar for the feed page. The app bar actions changes depending on the type of feed (general, community, user) class FeedPageAppBar extends StatefulWidget { @@ -60,36 +51,34 @@ class _FeedPageAppBarState extends State { @override Widget build(BuildContext context) { final feedBloc = context.read(); - final thunderBloc = context.read(); - final ProfileState profileState = context.read().state; + final hideTopBarOnScroll = context.select((cubit) => cubit.state.hideTopBarOnScroll); + final useProfilePictureForDrawer = context.select((cubit) => cubit.state.useProfilePictureForDrawer); + final isLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); + final feedChrome = context.select((bloc) => (status: bloc.state.status, feedType: bloc.state.feedType)); return BlocListener( - listenWhen: (previous, current) => current.status == FeedStatus.initial, - listener: (context, state) { - if (state.status == FeedStatus.initial) { - setState(() => showAppBarTitle = false); - } - }, + listenWhen: (previous, current) => previous.status != current.status && current.status == FeedStatus.initial, + listener: (context, state) => setState(() => showAppBarTitle = false), child: SliverAppBar( - pinned: !thunderBloc.state.hideTopBarOnScroll, + pinned: !hideTopBarOnScroll, floating: true, centerTitle: false, toolbarHeight: APP_BAR_HEIGHT, - surfaceTintColor: thunderBloc.state.hideTopBarOnScroll ? Colors.transparent : null, - title: FeedAppBarTitle(visible: (feedBloc.state.feedType == FeedType.general && feedBloc.state.status != FeedStatus.initial) ? true : showAppBarTitle), - leadingWidth: widget.scaffoldStateKey != null && thunderBloc.state.useProfilePictureForDrawer && profileState.isLoggedIn ? 50 : null, - leading: feedBloc.state.status == FeedStatus.initial + surfaceTintColor: hideTopBarOnScroll ? Colors.transparent : null, + title: FeedAppBarTitle(visible: (feedChrome.feedType == FeedType.general && feedChrome.status != FeedStatus.initial) ? true : showAppBarTitle), + leadingWidth: widget.scaffoldStateKey != null && useProfilePictureForDrawer && isLoggedIn ? 50 : null, + leading: feedChrome.status == FeedStatus.initial ? null - : _FeedDrawerButton( + : FeedDrawerButton( isRoot: widget.scaffoldStateKey != null, - showProfilePicture: thunderBloc.state.useProfilePictureForDrawer && profileState.isLoggedIn, + showProfilePicture: useProfilePictureForDrawer && isLoggedIn, onTap: () => _openDrawerOrGoBack(context, feedBloc), ), - actions: (feedBloc.state.status != FeedStatus.initial && feedBloc.state.status != FeedStatus.failureLoadingCommunity && feedBloc.state.status != FeedStatus.failureLoadingUser) + actions: (feedChrome.status != FeedStatus.initial && feedChrome.status != FeedStatus.failureLoadingCommunity && feedChrome.status != FeedStatus.failureLoadingUser) ? [ - if (feedBloc.state.feedType == FeedType.general) const FeedAppBarGeneralActions(), - if (feedBloc.state.feedType == FeedType.community) const FeedAppBarCommunityActions(), - if (feedBloc.state.feedType == FeedType.user) const FeedAppBarUserActions(), + if (feedChrome.feedType == FeedType.general) const FeedAppBarGeneralActions(), + if (feedChrome.feedType == FeedType.community) const FeedAppBarCommunityActions(), + if (feedChrome.feedType == FeedType.user) const FeedAppBarUserActions(), ] : [], ), @@ -103,257 +92,3 @@ class _FeedPageAppBarState extends State { : widget.scaffoldStateKey?.currentState?.openDrawer(); } } - -/// The title of the app bar. This shows the title (feed type, community, user) and the sort type -class FeedAppBarTitle extends StatelessWidget { - const FeedAppBarTitle({super.key, this.visible = true}); - - /// Whether to show the title. When the user scrolls down the title will be hidden - final bool visible; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final FeedBloc feedBloc = context.watch(); - - return AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: visible ? 1.0 : 0.0, - child: ListTile( - title: Text( - getAppBarTitle(feedBloc.state), - style: theme.textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( - children: [ - Icon(getSortIcon(feedBloc.state), size: 13), - const SizedBox(width: 4), - Text(getSortName(feedBloc.state)), - ], - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 0), - ), - ); - } -} - -/// The actions of the app bar for the community feed. -class FeedAppBarCommunityActions extends StatelessWidget { - const FeedAppBarCommunityActions({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - final feedBloc = context.read(); - final postSortType = feedBloc.state.postSortType; - - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), - onPressed: () { - HapticFeedback.mediumImpact(); - triggerRefresh(context); - }, - ), - IconButton( - icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), - onPressed: () { - HapticFeedback.mediumImpact(); - showModalBottomSheet( - showDragHandle: true, - context: context, - isScrollControlled: true, - builder: (builderContext) => SortPicker( - account: feedBloc.account, - title: l10n.sortOptions, - onSelect: (selected) async => context.read().add(FeedChangePostSortTypeEvent(selected.payload)), - previouslySelected: postSortType, - ), - ); - }, - ), - ], - ), - ); - } -} - -/// The actions of the app bar for the user feed. -class FeedAppBarUserActions extends StatelessWidget { - const FeedAppBarUserActions({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - final feedBloc = context.read(); - - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), - onPressed: () { - HapticFeedback.mediumImpact(); - triggerRefresh(context); - }, - ), - IconButton( - icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), - onPressed: () { - HapticFeedback.mediumImpact(); - - showModalBottomSheet( - showDragHandle: true, - context: context, - isScrollControlled: true, - builder: (builderContext) => SortPicker( - account: feedBloc.account, - title: l10n.sortOptions, - onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), - previouslySelected: feedBloc.state.postSortType, - ), - ); - }, - ), - ], - ), - ); - } -} - -/// The actions of the app bar for the general feed. -class FeedAppBarGeneralActions extends StatelessWidget { - const FeedAppBarGeneralActions({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - final feedBloc = context.read(); - - return Row( - children: [ - IconButton( - onPressed: () { - HapticFeedback.mediumImpact(); - triggerRefresh(context); - }, - icon: Icon(Icons.refresh_rounded, semanticLabel: l10n.refresh), - ), - IconButton( - icon: Icon(Icons.sort, semanticLabel: l10n.sortBy), - onPressed: () { - HapticFeedback.mediumImpact(); - - showModalBottomSheet( - showDragHandle: true, - context: context, - isScrollControlled: true, - builder: (builderContext) => SortPicker( - account: feedBloc.account, - title: l10n.sortOptions, - onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), - previouslySelected: feedBloc.state.postSortType, - ), - ); - }, - ), - Semantics( - label: l10n.menu, - child: PopupMenuButton( - onOpened: () => HapticFeedback.mediumImpact(), - itemBuilder: (context) => [ - ThunderPopupMenuItem( - onTap: () async { - HapticFeedback.mediumImpact(); - await navigateToModlogPage(context, subtitle: context.read().state.account.instance); - }, - icon: Icons.shield_rounded, - title: l10n.modlog, - ), - ], - ), - ), - ], - ); - } -} - -/// The drawer button for the feed page app bar. -/// -/// If [showProfilePicture] is true, then the profile picture will be shown. Otherwise, it falls back to the default back button. -class _FeedDrawerButton extends StatelessWidget { - const _FeedDrawerButton({ - required this.showProfilePicture, - required this.onTap, - required this.isRoot, - }); - - /// Whether the feed is the main feed. This is defined by a non-null [scaffoldStateKey]. - final bool isRoot; - - /// Whether to display the profile picture in place of the drawer icon. - final bool showProfilePicture; - - /// The function to call when the drawer is tapped. - final Function() onTap; - - @override - Widget build(BuildContext context) { - // Show profile picture only if it's the root feed and profile picture is enabled - if (isRoot && showProfilePicture) { - return _buildProfilePictureButton(context); - } - return _buildIconButton(context); - } - - Widget _buildProfilePictureButton(BuildContext context) { - final state = context.read().state; - - return Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Semantics( - label: MaterialLocalizations.of(context).openAppDrawerTooltip, - child: Stack( - children: [ - if (state.user != null) - Align( - alignment: Alignment.center, - child: UserAvatar(user: state.user!), - ), - Material( - color: Colors.transparent, - child: InkWell( - customBorder: const CircleBorder(), - onTap: onTap, - ), - ), - ], - ), - ), - ); - } - - Widget _buildIconButton(BuildContext context) { - final IconData icon; - final String semanticLabel; - - if (isRoot) { - icon = Icons.menu; - semanticLabel = MaterialLocalizations.of(context).openAppDrawerTooltip; - } else { - icon = (!kIsWeb && Platform.isIOS) ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back_rounded; - semanticLabel = MaterialLocalizations.of(context).backButtonTooltip; - } - - return IconButton( - icon: Icon(icon, semanticLabel: semanticLabel), - onPressed: onTap, - ); - } -} diff --git a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart index 14f90f26f..4667182fd 100644 --- a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart +++ b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart @@ -1,18 +1,15 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:visibility_detector/visibility_detector.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; -import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/feed/presentation/widgets/feed_post_card_list_item.dart'; +import 'package:thunder/src/features/feed/presentation/widgets/feed_read_tracking_controller.dart'; /// Widget representing the list of posts on the feed. class FeedPostCardList extends StatefulWidget { @@ -22,8 +19,8 @@ class FeedPostCardList extends StatefulWidget { /// Determines whether to mark posts as read on scroll final bool markPostReadOnScroll; - /// The list of posts that have been queued for removal using the dismiss read action - final List? queuedForRemoval; + /// The set of post ids that have been queued for removal using a dismiss action. + final Set? queuedForRemoval; /// The list of posts to show on the feed final List posts; @@ -93,32 +90,14 @@ class FeedPostCardList extends StatefulWidget { } class _FeedPostCardListState extends State { - /// The index of the last tapped post. - /// This is used to calculate the read status of posts in the range [0, lastTappedIndex] - int lastTappedIndex = -1; - - /// The index of the last processed post for read status. - int lastProcessedIndex = -1; - - /// Whether the user is scrolling down or not. The logic for determining read posts will - /// only be applied when the user is scrolling down - bool isScrollingDown = false; - - /// List of post ids to queue for being marked as read. - Set markReadPostIds = {}; - - /// List of post ids that have already previously been detected as read - Set readPostIds = {}; - - /// Timer for debouncing the read action - Timer? debounceTimer; + final FeedReadTrackingController _readTrackingController = FeedReadTrackingController(); /// The ID of the last post that the user tapped or navigated into int? lastTappedPost; @override void dispose() { - debounceTimer?.cancel(); + _readTrackingController.dispose(); super.dispose(); } @@ -131,137 +110,46 @@ class _FeedPostCardListState extends State { FeedListType? feedListType, bool isUserLoggedIn = false, }) { - Widget child = PostCard( + return FeedPostCardListItem( post: post, + index: index, feedType: feedType, feedListType: feedListType, - onVoteAction: (int voteType) async { - if (widget.onVoteAction != null) { - await widget.onVoteAction!(post, voteType); - return; - } - - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.vote, actionInput: VotePostInput(voteType))); - }, - onSaveAction: (bool saved) async { - if (widget.onSaveAction != null) { - await widget.onSaveAction!(post, saved); - return; - } - - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.save, actionInput: SavePostInput(saved))); - }, - onReadAction: (bool read) async { - if (widget.onReadAction != null) { - await widget.onReadAction!(post, read); - return; - } - - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.read, actionInput: ReadPostInput(read))); - }, - onHideAction: (bool hide) async { - if (widget.onHideAction != null) { - await widget.onHideAction!(post, hide); - widget.onDismissHiddenPost?.call(post.id); - return; - } - - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.hide, actionInput: HidePostInput(hide))); - context.read().add(FeedDismissHiddenPostEvent(postId: post.id)); - }, - onDownAction: () { - if (lastTappedIndex != index) lastTappedIndex = index; - }, - onUpAction: (double verticalDragDistance) { - bool updatedIsScrollingDown = verticalDragDistance < 0; - - if (isScrollingDown != updatedIsScrollingDown) { - isScrollingDown = updatedIsScrollingDown; - } - }, - onTap: () { - if (lastTappedPost != post.id) setState(() => lastTappedPost = post.id); - }, indicateRead: dim, - isLastTapped: lastTappedPost == post.id, disableSwiping: widget.disableSwiping, + markPostReadOnScroll: widget.markPostReadOnScroll, + isUserLoggedIn: isUserLoggedIn, + isLastTapped: lastTappedPost == post.id, + isQueuedForRemoval: widget.queuedForRemoval?.contains(post.id) == true, + onPostPressed: () => _readTrackingController.updateLastTappedIndex(index), + onPostDragEnded: _readTrackingController.updateScrollDirection, + onPostTapped: () { + if (lastTappedPost != post.id) setState(() => lastTappedPost = post.id); + }, + onPostNoLongerVisible: () => _readTrackingController.queueReadBatch( + index: index, + posts: widget.posts, + onBatchReady: _markPostsRead, + ), + onVoteAction: widget.onVoteAction, + onSaveAction: widget.onSaveAction, + onReadAction: widget.onReadAction, + onHideAction: widget.onHideAction, onPostUpdated: widget.onPostUpdated, onDismissHiddenPost: widget.onDismissHiddenPost, onDismissBlocked: widget.onDismissBlocked, ); + } - // Apply VisibilityDetector if [markPostReadOnScroll] is enabled - if (isUserLoggedIn && widget.markPostReadOnScroll) { - child = VisibilityDetector( - key: Key(post.apId), - onVisibilityChanged: (info) { - if (!isScrollingDown) return; - - if (index <= lastTappedIndex && info.visibleFraction == 0) { - // Debounce the read action to account for quick scrolling. This reduces the number of times the read action is triggered - debounceTimer?.cancel(); - - debounceTimer = Timer(const Duration(milliseconds: 500), () { - // TODO: Improve logic here so that we don't have to iterate through all posts if possible. - int startIndex = index; - int endIndex = lastProcessedIndex > 0 ? lastProcessedIndex : 0; - - for (int i = startIndex; i >= endIndex; i--) { - final post = widget.posts[i]; - - // If we already checked this post's read status, or we already marked it as read, skip it - if (readPostIds.contains(post.id) || markReadPostIds.contains(post.id)) continue; - - // Otherwise, check the post read status. If it's unread, queue it for marking as read - if (post.read == false) markReadPostIds.add(post.id); - readPostIds.add(post.id); - } - - // Update the last processed index - if (index > lastProcessedIndex) lastProcessedIndex = index; - - if (markReadPostIds.isNotEmpty) { - if (widget.onMultiReadAction != null) { - widget.onMultiReadAction!([...markReadPostIds], true); - } else { - context.read().add(FeedItemActionedEvent(postIds: [...markReadPostIds], postAction: PostAction.multiRead, actionInput: const MultiReadPostInput(true))); - } - readPostIds.addAll(markReadPostIds); // Add all post ids that were queued to prevent them from being queued again - markReadPostIds = {}; // Reset the list of post ids to mark as read - } - }); - } - }, - child: child, - ); - } + void _markPostsRead(List postIds) { + if (postIds.isEmpty) return; - // Only apply dismissal animation when the post is queued for removal - final isQueuedForRemoval = widget.queuedForRemoval?.contains(post.id) == true; - - if (isQueuedForRemoval) { - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 300), - tween: Tween(begin: 1.0, end: 0.0), - builder: (context, value, animatedChild) { - return ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: value, - child: Opacity( - opacity: value, - child: Transform.translate( - offset: Offset((1 - value) * 100, 0), - child: child, - ), - ), - ), - ); - }, - ); + if (widget.onMultiReadAction != null) { + widget.onMultiReadAction!(postIds, true); + return; } - return child; + context.read().add(FeedItemActionedEvent(postIds: postIds, postAction: PostAction.multiRead, actionInput: const MultiReadPostInput(true))); } @override diff --git a/lib/src/features/feed/presentation/widgets/feed_post_card_list_item.dart b/lib/src/features/feed/presentation/widgets/feed_post_card_list_item.dart new file mode 100644 index 000000000..5bcc7c426 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_post_card_list_item.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// A single feed post row with action wiring, read tracking, and dismissal animation. +class FeedPostCardListItem extends StatelessWidget { + const FeedPostCardListItem({ + super.key, + required this.post, + required this.index, + required this.feedType, + required this.feedListType, + required this.indicateRead, + required this.disableSwiping, + required this.markPostReadOnScroll, + required this.isUserLoggedIn, + required this.isLastTapped, + required this.isQueuedForRemoval, + required this.onPostTapped, + required this.onPostPressed, + required this.onPostDragEnded, + required this.onPostNoLongerVisible, + this.onVoteAction, + this.onSaveAction, + this.onReadAction, + this.onHideAction, + this.onPostUpdated, + this.onDismissHiddenPost, + this.onDismissBlocked, + }); + + /// The post displayed by this row. + final ThunderPost post; + + /// The index of [post] in the visible feed list. + final int index; + + /// The feed context used by [PostCard]. + final FeedType? feedType; + + /// The feed list type used by [PostCard]. + final FeedListType? feedListType; + + /// Whether the row should visually indicate read state. + final bool indicateRead; + + /// Whether swipe gestures are disabled for this row. + final bool disableSwiping; + + /// Whether visibility changes should mark posts read. + final bool markPostReadOnScroll; + + /// Whether the current account can mark posts read. + final bool isUserLoggedIn; + + /// Whether this post was the last tapped row. + final bool isLastTapped; + + /// Whether this row is animating out before removal. + final bool isQueuedForRemoval; + + /// Called when the row is tapped. + final VoidCallback onPostTapped; + + /// Called when the row receives a press/down gesture. + final VoidCallback onPostPressed; + + /// Called when a row drag ends. + final ValueChanged onPostDragEnded; + + /// Called when the row leaves the viewport during read-on-scroll tracking. + final VoidCallback onPostNoLongerVisible; + + /// Optional callback for voting a post. + final Future Function(ThunderPost post, int voteType)? onVoteAction; + + /// Optional callback for saving a post. + final Future Function(ThunderPost post, bool saved)? onSaveAction; + + /// Optional callback for toggling read state. + final Future Function(ThunderPost post, bool read)? onReadAction; + + /// Optional callback for toggling hidden state. + final Future Function(ThunderPost post, bool hidden)? onHideAction; + + /// Optional callback for replacing the post in the parent list. + final void Function(ThunderPost post)? onPostUpdated; + + /// Optional callback for dismissing hidden posts from view. + final void Function(int postId)? onDismissHiddenPost; + + /// Optional callback for dismissing blocked content from view. + final void Function({int? userId, int? communityId})? onDismissBlocked; + + @override + Widget build(BuildContext context) { + Widget child = PostCard( + post: post, + feedType: feedType, + feedListType: feedListType, + onVoteAction: (voteType) => _vote(context, voteType), + onSaveAction: (saved) => _save(context, saved), + onReadAction: (read) => _read(context, read), + onHideAction: (hide) => _hide(context, hide), + onDownAction: onPostPressed, + onUpAction: onPostDragEnded, + onTap: onPostTapped, + indicateRead: indicateRead, + isLastTapped: isLastTapped, + disableSwiping: disableSwiping, + onPostUpdated: onPostUpdated, + onDismissHiddenPost: onDismissHiddenPost, + onDismissBlocked: onDismissBlocked, + ); + + if (isUserLoggedIn && markPostReadOnScroll) { + child = VisibilityDetector( + key: Key(post.apId), + onVisibilityChanged: (info) { + if (info.visibleFraction == 0) { + onPostNoLongerVisible(); + } + }, + child: child, + ); + } + + if (!isQueuedForRemoval) { + return child; + } + + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + tween: Tween(begin: 1.0, end: 0.0), + builder: (context, value, animatedChild) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: value, + child: Opacity( + opacity: value, + child: Transform.translate( + offset: Offset((1 - value) * 100, 0), + child: child, + ), + ), + ), + ); + }, + ); + } + + Future _vote(BuildContext context, int voteType) async { + if (onVoteAction != null) { + await onVoteAction!(post, voteType); + return; + } + + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.vote, actionInput: VotePostInput(voteType))); + } + + Future _save(BuildContext context, bool saved) async { + if (onSaveAction != null) { + await onSaveAction!(post, saved); + return; + } + + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.save, actionInput: SavePostInput(saved))); + } + + Future _read(BuildContext context, bool read) async { + if (onReadAction != null) { + await onReadAction!(post, read); + return; + } + + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.read, actionInput: ReadPostInput(read))); + } + + Future _hide(BuildContext context, bool hide) async { + if (onHideAction != null) { + await onHideAction!(post, hide); + onDismissHiddenPost?.call(post.id); + return; + } + + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.hide, actionInput: HidePostInput(hide))); + if (hide) { + await FeedActionScope.maybeOf(context)?.dismissHiddenPost(post.id); + } + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_reached_end.dart b/lib/src/features/feed/presentation/widgets/feed_reached_end.dart new file mode 100644 index 000000000..61d28c3b2 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_reached_end.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Footer shown when a paginated feed has no more items to load. +class FeedReachedEnd extends StatelessWidget { + const FeedReachedEnd({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: theme.dividerColor.withValues(alpha: 0.1), + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: ScalableText( + l10n.reachedTheBottom, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + textScaleFactor: metadataFontSizeScale.textScaleFactor, + ), + ), + const SizedBox(height: 160.0), + ], + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_read_tracking_controller.dart b/lib/src/features/feed/presentation/widgets/feed_read_tracking_controller.dart new file mode 100644 index 000000000..406b65e3d --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_read_tracking_controller.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:thunder/src/features/post/post.dart'; + +/// Tracks feed-row visibility and batches unread post ids for read-on-scroll. +class FeedReadTrackingController { + /// The index of the last tapped post. + int lastTappedIndex = -1; + + /// The index of the last processed post for read status. + int lastProcessedIndex = -1; + + /// Whether the user is currently scrolling down. + bool isScrollingDown = false; + + final Set _queuedPostIds = {}; + final Set _processedPostIds = {}; + Timer? _debounceTimer; + + /// Releases the pending read debounce timer. + void dispose() { + _debounceTimer?.cancel(); + } + + /// Records which row was most recently pressed. + void updateLastTappedIndex(int index) { + lastTappedIndex = index; + } + + /// Updates the current scroll direction from a vertical drag distance. + void updateScrollDirection(double verticalDragDistance) { + isScrollingDown = verticalDragDistance < 0; + } + + /// Queues unread ids between the hidden row and the previous processed row. + void queueReadBatch({ + required int index, + required List posts, + required void Function(List postIds) onBatchReady, + }) { + if (!isScrollingDown || index > lastTappedIndex || index < 0 || index >= posts.length) { + return; + } + + final endIndex = lastProcessedIndex > 0 ? lastProcessedIndex : 0; + if (index < endIndex) return; + + for (int i = index; i >= endIndex; i--) { + final post = posts[i]; + if (_processedPostIds.contains(post.id) || _queuedPostIds.contains(post.id)) { + continue; + } + + if (post.read == false) { + _queuedPostIds.add(post.id); + } + _processedPostIds.add(post.id); + } + + if (index > lastProcessedIndex) { + lastProcessedIndex = index; + } + + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + if (_queuedPostIds.isEmpty) return; + + final postIds = List.unmodifiable(_queuedPostIds); + _processedPostIds.addAll(postIds); + _queuedPostIds.clear(); + onBatchReady(postIds); + }); + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_scroll_body.dart b/lib/src/features/feed/presentation/widgets/feed_scroll_body.dart new file mode 100644 index 000000000..f5d2c9ca8 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_scroll_body.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/feed/feed.dart'; + +/// Scrollable sliver body for a feed page. +class FeedScrollBody extends StatelessWidget { + const FeedScrollBody({ + super.key, + required this.scrollController, + required this.selectedSubview, + required this.queuedForRemoval, + required this.onChangeFeedType, + this.scaffoldStateKey, + this.feedType, + }); + + /// Scroll controller shared with the app bar and page-level scroll actions. + final ScrollController scrollController; + + /// Optional scaffold key used by root feeds to open the subscriptions drawer. + final GlobalKey? scaffoldStateKey; + + /// Route-level feed type used to choose the correct app bar. + final FeedType? feedType; + + /// Selected user-profile subview. + final FeedTypeSubview selectedSubview; + + /// Post ids currently animating out of the post list. + final ValueListenable> queuedForRemoval; + + /// Called when the user switches between posts and comments in a user header. + final ValueChanged onChangeFeedType; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.status != current.status || + previous.posts != current.posts || + previous.comments != current.comments || + previous.hasReachedPostsEnd != current.hasReachedPostsEnd || + previous.hasReachedCommentsEnd != current.hasReachedCommentsEnd || + previous.feedType != current.feedType || + previous.community != current.community || + previous.communityInstance != current.communityInstance || + previous.communityModerators != current.communityModerators || + previous.user != current.user || + previous.userModerates != current.userModerates, + builder: (context, state) { + return CustomScrollView( + controller: scrollController, + slivers: [ + feedType == FeedType.account ? AccountPageAppBar(scrollController: scrollController) : FeedPageAppBar(scrollController: scrollController, scaffoldStateKey: scaffoldStateKey), + if (state.status == FeedStatus.initial) const FeedInitialLoadingSliver(), + if (state.status == FeedStatus.failureLoadingCommunity || state.status == FeedStatus.failureLoadingUser) const SliverToBoxAdapter(child: SizedBox.shrink()), + if (_shouldShowFeedContent(state)) ...[ + FeedHeaderSliver( + state: state, + selectedSubview: selectedSubview, + onChangeFeedType: onChangeFeedType, + ), + ValueListenableBuilder>( + valueListenable: queuedForRemoval, + builder: (context, queuedPostIds, child) { + return FeedContentSliver( + state: state, + selectedSubview: selectedSubview, + queuedForRemoval: queuedPostIds, + ); + }, + ), + FeedBottomSliver( + state: state, + selectedSubview: selectedSubview, + ), + ], + ], + ); + }, + ); + } + + bool _shouldShowFeedContent(FeedState state) { + return state.status != FeedStatus.initial && state.status != FeedStatus.failureLoadingCommunity && state.status != FeedStatus.failureLoadingUser; + } +} diff --git a/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart b/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart new file mode 100644 index 000000000..737798038 --- /dev/null +++ b/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; + +/// Safe-area scrim shown when the top app bar hides during feed scrolling. +class FeedTopBarScrim extends StatelessWidget { + const FeedTopBarScrim({super.key}); + + @override + Widget build(BuildContext context) { + final hideTopBarOnScroll = context.select((bloc) => bloc.state.hideTopBarOnScroll); + if (!hideTopBarOnScroll) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final padding = MediaQuery.paddingOf(context).top; + + return Positioned( + child: Container( + height: padding, + color: theme.colorScheme.surface, + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/widgets.dart b/lib/src/features/feed/presentation/widgets/widgets.dart index 91cd0891c..3ce26e6de 100644 --- a/lib/src/features/feed/presentation/widgets/widgets.dart +++ b/lib/src/features/feed/presentation/widgets/widgets.dart @@ -1,5 +1,19 @@ export 'feed_page_app_bar.dart'; +export 'feed_app_bar_community_actions.dart'; +export 'feed_app_bar_general_actions.dart'; +export 'feed_app_bar_title.dart'; +export 'feed_app_bar_user_actions.dart'; export 'feed_post_card_list.dart'; export 'feed_comment_card_list.dart'; export 'feed_card_divider.dart'; +export 'feed_drawer_button.dart'; export 'feed_fab.dart'; +export 'feed_bottom_sliver.dart'; +export 'feed_content_sliver.dart'; +export 'feed_fab_overlay.dart'; +export 'feed_header.dart'; +export 'feed_header_sliver.dart'; +export 'feed_initial_loading_sliver.dart'; +export 'feed_scroll_body.dart'; +export 'feed_reached_end.dart'; +export 'feed_top_bar_scrim.dart'; diff --git a/lib/src/features/post/domain/utils/comment_state_utils.dart b/lib/src/features/post/domain/utils/comment_state_utils.dart index 538676287..3b3c1ee44 100644 --- a/lib/src/features/post/domain/utils/comment_state_utils.dart +++ b/lib/src/features/post/domain/utils/comment_state_utils.dart @@ -1,5 +1,6 @@ import 'package:thunder/src/features/comment/comment.dart'; +/// Creates a deep copy of a comment tree while preserving comment values. CommentNode clone(CommentNode root) { return CommentNode( comment: root.comment, @@ -7,12 +8,15 @@ CommentNode clone(CommentNode root) { ); } -List update({ - required List current, +/// Returns a new collapsed-comment set after applying a single collapse toggle. +Set update({ + required Set current, required int commentId, required bool collapsed, }) { - final updated = current.toList(); + if (collapsed == current.contains(commentId)) return current; + + final updated = current.toSet(); if (collapsed) { updated.add(commentId); } else { @@ -20,3 +24,46 @@ List update({ } return updated; } + +/// Returns a copy of [root] with the node matching [comment] replaced. +CommentNode replaceComment(CommentNode root, ThunderComment comment) { + return CommentNode( + comment: root.comment?.id == comment.id ? comment : root.comment, + replies: root.replies.map((reply) => replaceComment(reply, comment)).toList(), + ); +} + +/// Computes comments hidden by collapsed ancestors. +Set hiddenCommentIds({ + required Iterable comments, + required Set collapsedCommentIds, +}) { + if (collapsedCommentIds.isEmpty) return const {}; + + final hiddenIds = {}; + for (final node in comments) { + final comment = node.comment; + if (comment == null) continue; + if (isHiddenByCollapsedAncestor(comment.path, comment.id, collapsedCommentIds)) { + hiddenIds.add(comment.id); + } + } + return hiddenIds; +} + +/// Whether [commentId] has a collapsed ancestor in its dot-separated path. +bool isHiddenByCollapsedAncestor(String path, int commentId, Set collapsedCommentIds) { + for (final ancestorId in _ancestorIds(path)) { + if (ancestorId != commentId && collapsedCommentIds.contains(ancestorId)) { + return true; + } + } + return false; +} + +Iterable _ancestorIds(String path) sync* { + for (final segment in path.split('.')) { + final id = int.tryParse(segment); + if (id != null) yield id; + } +} diff --git a/lib/src/features/post/presentation/pages/post_page.dart b/lib/src/features/post/presentation/pages/post_page.dart index 71d4cee94..6f855a29d 100644 --- a/lib/src/features/post/presentation/pages/post_page.dart +++ b/lib/src/features/post/presentation/pages/post_page.dart @@ -1,27 +1,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; -import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/data/cache/profile_site_info_cache.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/shared/error_message.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; -import 'package:thunder/src/shared/text/selectable_text_modal.dart'; -import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; -import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_fab_overlay.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_page_floating_action_button.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_page_scroll_body.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_top_bar_scrim.dart'; import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// A page that displays the post details and comments associated with a post. @@ -76,30 +69,26 @@ class _PostPageState extends State { Timer? _updateScrollPositionTimer; Set _blockedCommunityIds = const {}; final Set _notifiedBlockedPostIds = {}; + ThunderPost? _lastNotifiedPost; + int? _lastAutoScrolledCommentId; @override void initState() { super.initState(); _loadBlockedCommunities(); + _loadInitialPost(); highlightedCommentId = widget.highlightedCommentId; - scrollController.addListener(() { - final state = context.read().state; - final isPastThreshold = scrollController.position.pixels > scrollController.position.maxScrollExtent * 0.7; - - // Fetches new comments when the user has scrolled past 70% list - if (isPastThreshold && state.status == PostStatus.success && !state.hasReachedCommentEnd) { - context.read().add(const GetPostCommentsEvent()); - } - - _updateScrollPosition(); - }); + scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { if (!hasSetInitialScroll) { hasSetInitialScroll = true; - scrollController.jumpTo(context.read().state.scrollPosition ?? 0.0); + final scrollPosition = context.read().state.scrollPosition; + if (scrollController.hasClients && scrollPosition != null) { + scrollController.jumpTo(scrollPosition); + } } }); } @@ -112,11 +101,39 @@ class _PostPageState extends State { super.dispose(); } + /// Starts the initial post fetch once the bloc is available. + void _loadInitialPost() { + final bloc = context.read(); + if (bloc.state.status != PostStatus.initial) return; + + bloc.add( + GetPostEvent( + post: widget.initialPost, + selectedCommentPath: widget.commentPath, + ), + ); + } + + /// Handles pagination and scroll-position persistence for the post page. + void _onScroll() { + if (!scrollController.hasClients) return; + + final state = context.read().state; + final isPastThreshold = scrollController.position.pixels > scrollController.position.maxScrollExtent * 0.7; + + if (isPastThreshold && state.status == PostStatus.success && !state.hasReachedCommentEnd) { + context.read().add(const GetPostCommentsPageEvent()); + } + + _updateScrollPosition(); + } + /// Updates the scroll position in the cubit after scrolling has stopped void _updateScrollPosition() { _updateScrollPositionTimer?.cancel(); _updateScrollPositionTimer = Timer(const Duration(milliseconds: 150), () { + if (!mounted || !scrollController.hasClients) return; context.read().updateScrollPosition(scrollController.position.pixels); }); } @@ -146,15 +163,15 @@ class _PostPageState extends State { showSnackbar(GlobalContext.l10n.noVisibleComments); } - bool listenWhen(PostState previous, PostState current) { - if (previous.status == PostStatus.loading && current.status == PostStatus.success && current.post != null && current.hasReachedCommentEnd) { - _maybeShowBlockedCommunityMessage(current.post!); - } - - return true; + bool _listenWhen(PostState previous, PostState current) { + return previous.status != current.status || + previous.post != current.post || + previous.comments != current.comments || + previous.errorMessage != current.errorMessage || + previous.hasReachedCommentEnd != current.hasReachedCommentEnd; } - void listener(BuildContext context, PostState state) { + void _listener(BuildContext context, PostState state) { final l10n = GlobalContext.l10n; final navigationState = context.read().state; @@ -163,373 +180,109 @@ class _PostPageState extends State { return; } - if (state.status == PostStatus.success && state.post != null) { + if (state.status == PostStatus.success && state.post != null && _lastNotifiedPost != state.post) { widget.onPostUpdated?.call(state.post!); - setState(() {}); + _lastNotifiedPost = state.post; + } + + if (state.status == PostStatus.success && state.post != null && state.hasReachedCommentEnd) { + _maybeShowBlockedCommunityMessage(state.post!); } if (state.status == PostStatus.failure) { showSnackbar(state.errorMessage ?? l10n.missingErrorMessage); } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - final thunderState = context.read().state; - - final account = context.read().account; - - return BlocConsumer( - listenWhen: listenWhen, - listener: listener, - buildWhen: (previous, current) { - final navigationState = context.read().state; - // Don't rebuild if only scroll position changed - if (navigationState.didScrollPositionChange) { - return false; - } - // Rebuild if relevant state properties changed - return previous.status != current.status || - previous.post != current.post || - previous.comments != current.comments || - previous.crossPosts != current.crossPosts || - previous.errorMessage != current.errorMessage; - }, - builder: (context, state) { - if (state.status == PostStatus.initial) { - // This is required because listener does not get called on initial build - context.read().add( - GetPostEvent( - post: widget.initialPost, - selectedCommentPath: widget.commentPath, - ), - ); - } - final post = state.post ?? widget.initialPost; - - // Check to see if there is a highlighted comment. If there is, check to see if it is visible. - // If it is not visible, scroll to it. - final navigationState = context.read().state; - final highlightedCommentId = navigationState.highlightedCommentId; - final highlightedCommentIndex = state.comments.indexWhere((element) => element.comment?.id == highlightedCommentId); - - if (widget.highlightedCommentId != null && listController.isAttached && highlightedCommentIndex != -1) { - final visibleRange = listController.visibleRange; - // Add 1 to account for the placeholder widget at index 0 - final adjustedIndex = highlightedCommentIndex + 1; - - if (visibleRange != null && (adjustedIndex < (visibleRange.$1 + 3) || adjustedIndex > (visibleRange.$2 - 3))) { - // Defer animation to after the frame is rendered so that list extents are available - WidgetsBinding.instance.addPostFrameCallback((_) { - if (listController.isAttached) { - listController.animateToItem( - index: adjustedIndex, - scrollController: scrollController, - alignment: 0, - duration: (estimatedDistance) => const Duration(milliseconds: 250), - curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, - ); - } - }); - } - } - - return RefreshIndicator( - onRefresh: () async { - HapticFeedback.mediumImpact(); - - final navigationState = context.read().state; - if (navigationState.highlightedCommentId != null) { - // If we're viewing a specific comment thread, refresh with that context unless "View All Comments" is pressed - context.read().add(GetPostEvent(postId: widget.initialPost.id, selectedCommentPath: widget.commentPath)); - } else { - context.read().add(GetPostEvent(postId: widget.initialPost.id)); - } - }, - edgeOffset: MediaQuery.of(context).padding.top + APP_BAR_HEIGHT, // This offset is placed to allow the correct positioning of the refresh indicator - child: Scaffold( - floatingActionButton: PostPageFAB(post: post, comments: state.comments, scrollController: scrollController, listController: listController), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - body: SafeArea( - top: false, - bottom: false, - child: Stack( - children: [ - CustomScrollView( - controller: scrollController, - cacheExtent: 1000, - slivers: [ - PostPageAppBar( - key: appBarKey, - viewSource: viewSource, - onViewSource: (value) => setState(() => viewSource = value), - onReset: () async => await scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized), - onCreateCrossPost: () { - createCrossPost( - context, - title: state.post?.name ?? '', - url: state.post?.url, - text: state.post?.body, - postUrl: state.post?.apId, - ); - }, - onSelectText: () { - showSelectableTextModal( - context, - title: state.post?.name ?? '', - text: state.post?.body ?? '', - ); - }, - onPostChanged: (post) => context.read().add(GetPostEvent(post: post)), - highlightedCommentId: this.highlightedCommentId, - commentPath: widget.commentPath, - ), - if (state.status == PostStatus.initial || state.status == PostStatus.loading) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) - else if (state.status == PostStatus.failure) - SliverFillRemaining( - hasScrollBody: false, - child: _PostPageError( - onRetry: () { - final navigationState = context.read().state; - if (navigationState.highlightedCommentId != null) { - // If we're viewing a specific comment thread, retry with that context unless "View All Comments" is pressed - context.read().add(GetPostEvent(postId: widget.initialPost.id, selectedCommentPath: widget.commentPath)); - } else { - context.read().add(GetPostEvent(postId: widget.initialPost.id)); - } - }, - ), - ) - else ...[ - SliverList.list( - children: [ - PostBody( - post: post, - crossPosts: state.crossPosts, - viewSource: viewSource, - showCompactPostBody: widget.highlightedCommentId != null, - ), - if (state.status != PostStatus.loading && navigationState.highlightedCommentId != null) - InkWell( - child: Container( - height: 60.0, - decoration: BoxDecoration(border: Border(top: BorderSide(color: theme.dividerColor))), - child: Row( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(l10n.viewAllComments, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500)), - Icon(Icons.arrow_right_alt_rounded), - ], - ), - ), - onTap: () { - context.read().add(const GetPostCommentsEvent(reset: true, commentParentId: null)); - context.read().setHighlightedCommentId(null); - }, - ), - ], - ), - SuperSliverList.builder( - itemCount: state.comments.length + 1, - listController: listController, - itemBuilder: (BuildContext context, int index) { - if (index == 0) { - // This is a placeholder widget to allow the comment scroller to work properly for the first comment - // Note: CommentNavigatorFab indexes will be shifted by 1 to account for the placeholder widget - return const SizedBox(height: 1); - } - - final commentNode = state.comments[index - 1]; - final comment = commentNode.comment; - - // Skip rendering if comment is null - if (comment == null) { - return const SizedBox.shrink(); - } - - final collapsedComments = context.read().state.collapsedComments; - - final isCollapsed = collapsedComments.contains(comment.id); - final isHidden = collapsedComments.any((int id) => comment.path.contains('$id') && id != comment.id); - - return CommentCard( - key: ValueKey(comment.id), - account: account, - comment: comment, - onCommentUpdated: (comment) { - context.read().add(CommentItemUpdatedEvent(comment: comment)); - context.read().setHighlightedCommentId(null); - }, - onCommentInserted: (comment) { - context.read().add(CommentItemInsertedEvent(comment: comment)); - context.read().setHighlightedCommentId(comment.id); - }, - level: commentNode.depth, - replies: commentNode.replies.length, - collapsed: isCollapsed, - hidden: isHidden, - highlight: comment.id == navigationState.highlightedCommentId, - onCollapse: (int commentId, bool collapsed) { - context.read().add(UpdateCollapsedComment(commentId: commentId, collapsed: collapsed)); - setState(() {}); - }, - ); - }, - ), - SliverToBoxAdapter( - child: _PostPageFeedEnd( - key: ValueKey(widget.initialPost.apId), - appBarKey: appBarKey, - ), - ), - ], - ], - ), - if (thunderState.hideTopBarOnScroll) Positioned(child: Container(height: MediaQuery.of(context).padding.top, color: theme.colorScheme.surface)), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: context.select((cubit) => cubit.state.isPostFabOpen) - ? Listener( - onPointerUp: (details) => context.read().setPostFabOpen(false), - child: Container(color: theme.colorScheme.surface.withValues(alpha: 0.95)), - ) - : null, - ), - ], - ), - ), - ), - ); - }, - ); + _maybeScrollToHighlightedComment(state); } -} - -/// Displays a widget when the user reaches the end of the comment list. -/// -/// If there are additional comments to be loaded, a loading indicator is displayed. -/// Otherwise, a message is displayed saying that no comments were found. -class _PostPageFeedEnd extends StatefulWidget { - /// The key for the app bar - final GlobalKey appBarKey; - const _PostPageFeedEnd({super.key, required this.appBarKey}); - - @override - State<_PostPageFeedEnd> createState() => _PostPageFeedEndState(); -} - -class _PostPageFeedEndState extends State<_PostPageFeedEnd> { - /// The key for the "reached end" indicator - final GlobalKey reachedEndKey = GlobalKey(); - - /// The height of the bottom spacer - double? bottomSpacerHeight; - - /// The timer for calculating the bottom spacer height - Timer? _calculateBottomSpacerTimer; - - // The following logic helps us to set the size of the bottom spacer so that the user can scroll the last comment to the top of the viewport but no further. - // This must be run some time after the layout has been rendered so we can measure everything. - Future _getBottomSpacerHeight() async { - final deviceHeight = MediaQuery.sizeOf(context).height; + /// Scrolls the initially highlighted comment into view after comments load. + void _maybeScrollToHighlightedComment(PostState state) { + final highlightedCommentId = context.read().state.highlightedCommentId; + if (widget.highlightedCommentId == null || highlightedCommentId == null || _lastAutoScrolledCommentId == highlightedCommentId) { + return; + } - // Get the height of the "reached end" indicator widget - final reachedEndHeight = (reachedEndKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; + final highlightedCommentIndex = state.comments.indexWhere((element) => element.comment?.id == highlightedCommentId); + if (highlightedCommentIndex == -1) return; - // Get the height of the app bar - final renderObject = widget.appBarKey.currentContext?.findRenderObject() as RenderSliverFloatingPersistentHeader?; - final appBarHeight = renderObject?.geometry!.maxPaintExtent; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !listController.isAttached) return; + + final visibleRange = listController.visibleRange; + if (visibleRange == null) return; + + // Add 1 to account for the placeholder item at the start of the sliver list. + final adjustedIndex = highlightedCommentIndex + 1; + if (adjustedIndex < (visibleRange.$1 + 3) || adjustedIndex > (visibleRange.$2 - 3)) { + listController.animateToItem( + index: adjustedIndex, + scrollController: scrollController, + alignment: 0, + duration: (_) => const Duration(milliseconds: 250), + curve: (_) => Curves.easeInOutCubicEmphasized, + ); + } - if (appBarHeight != null && reachedEndHeight != null) { - // We will make the bottom spacer the size of the device height, minus the size of the app bar and the size of the "reached bottom" indicator. - // This will allow the last comment to be scrolled to the top, with the "reached bottom" indicator and the spacer taking up the rest of the space. - bottomSpacerHeight = deviceHeight - appBarHeight - reachedEndHeight; - setState(() {}); - } + _lastAutoScrolledCommentId = highlightedCommentId; + }); } - @override - void dispose() { - _calculateBottomSpacerTimer?.cancel(); - super.dispose(); + Future _resetScroll() async { + if (!scrollController.hasClients) return; + await scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized); } - @override - Widget build(BuildContext context) { - final height = 0.0; - - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - final comments = context.select>((bloc) => bloc.state.comments); - final hasReachedCommentEnd = context.select((bloc) => bloc.state.hasReachedCommentEnd); - final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); - - if (bottomSpacerHeight == null) { - if (_calculateBottomSpacerTimer != null) { - _calculateBottomSpacerTimer!.cancel(); - } - _calculateBottomSpacerTimer = Timer(Duration(milliseconds: 250), _getBottomSpacerHeight); - } - - Widget child = Container( - height: 100.0, - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: const CircularProgressIndicator(), - ); - - if (hasReachedCommentEnd == true) { - child = Container( - key: reachedEndKey, - color: theme.dividerColor.withValues(alpha: 0.1), - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( - comments.isEmpty ? l10n.noCommentsFound : l10n.endOfComments, - textScaleFactor: metadataFontSizeScale.textScaleFactor, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - ), - ); + void _refreshPost() { + final navigationState = context.read().state; + if (navigationState.highlightedCommentId != null) { + context.read().add(GetPostEvent(postId: widget.initialPost.id, selectedCommentPath: widget.commentPath)); + } else { + context.read().add(GetPostEvent(postId: widget.initialPost.id)); } - - return Container( - margin: EdgeInsets.only(bottom: bottomSpacerHeight ?? height), - child: child, - ); } -} - -/// The error widget for the post page. -class _PostPageError extends StatelessWidget { - final Function() onRetry; - - const _PostPageError({required this.onRetry}); @override Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - - return Center( - child: ErrorMessage( - title: l10n.unableToLoadPost, - message: l10n.internetOrInstanceIssues, - actions: [ - ( - text: l10n.retry, - action: onRetry, - loading: false, + return BlocListener( + listenWhen: _listenWhen, + listener: _listener, + child: RefreshIndicator( + onRefresh: () async { + HapticFeedback.mediumImpact(); + _refreshPost(); + }, + edgeOffset: MediaQuery.of(context).padding.top + APP_BAR_HEIGHT, + child: Scaffold( + floatingActionButton: PostPageFloatingActionButton( + initialPost: widget.initialPost, + scrollController: scrollController, + listController: listController, ), - ], + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + body: SafeArea( + top: false, + bottom: false, + child: Stack( + children: [ + PostPageScrollBody( + scrollController: scrollController, + listController: listController, + appBarKey: appBarKey, + initialPost: widget.initialPost, + viewSource: viewSource, + onViewSource: (value) => setState(() => viewSource = value), + onReset: _resetScroll, + onRetry: _refreshPost, + highlightedCommentId: highlightedCommentId, + commentPath: widget.commentPath, + ), + const PostTopBarScrim(), + const PostFabOverlay(), + ], + ), + ), + ), ), ); } diff --git a/lib/src/features/post/presentation/state/post_bloc.dart b/lib/src/features/post/presentation/state/post_bloc.dart index c8c9b925f..eb6ea347d 100644 --- a/lib/src/features/post/presentation/state/post_bloc.dart +++ b/lib/src/features/post/presentation/state/post_bloc.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; + import 'package:thunder/src/foundation/contracts/contracts.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/errors/errors.dart'; @@ -34,14 +36,16 @@ class PostBloc extends Bloc { }) : _preferencesStore = preferencesStore, _localizationService = localizationService, super(PostState()) { - on(_getPostEvent); - on(_getPostCommentsEvent); + on(_getPostEvent, transformer: restartable()); + on(_getPostCommentsEvent, transformer: restartable()); + on(_getPostCommentsPageEvent, transformer: droppable()); + on(_getPostCommentRepliesEvent, transformer: droppable()); on(_reportCommentEvent); on(_votePostEvent); on(_savePostEvent); - on(_commentActionEvent); - on(_commentItemUpdatedEvent); - on(_commentItemInsertedEvent); + on(_commentActionEvent, transformer: sequential()); + on(_commentItemUpdatedEvent, transformer: sequential()); + on(_commentItemInsertedEvent, transformer: sequential()); on(_onUpdateCollapsedComment); on(_onPostUpdated); } @@ -139,20 +143,21 @@ class PostBloc extends Bloc { } try { - // Optimistically update the post ThunderPost updatedPost = optimisticallyVotePost(originalPost, event.score); - // Immediately set the status with optimistic update - emit(state.copyWith(status: PostStatus.success, post: updatedPost)); - emit(state.copyWith(status: PostStatus.refreshing)); + if (updatedPost != state.post) { + emit(state.copyWith(status: PostStatus.success, post: updatedPost, errorReason: null)); + } updatedPost = await postRepository.vote(originalPost, event.score); - return emit(state.copyWith( - status: PostStatus.success, - post: updatedPost, - errorReason: null, - )); + if (updatedPost != state.post) { + return emit(state.copyWith( + status: PostStatus.success, + post: updatedPost, + errorReason: null, + )); + } } catch (e) { final message = getExceptionErrorMessage(e); return emit(state.copyWith( @@ -181,20 +186,21 @@ class PostBloc extends Bloc { } try { - // Optimistically update the post ThunderPost updatedPost = optimisticallySavePost(originalPost, event.save); - // Immediately set the status with optimistic update - emit(state.copyWith(status: PostStatus.success, post: updatedPost)); - emit(state.copyWith(status: PostStatus.refreshing)); + if (updatedPost != state.post) { + emit(state.copyWith(status: PostStatus.success, post: updatedPost, errorReason: null)); + } updatedPost = await postRepository.save(originalPost, event.save); - return emit(state.copyWith( - status: PostStatus.success, - post: updatedPost, - errorReason: null, - )); + if (updatedPost != state.post) { + return emit(state.copyWith( + status: PostStatus.success, + post: updatedPost, + errorReason: null, + )); + } } catch (e) { final message = getExceptionErrorMessage(e); return emit(state.copyWith( @@ -211,14 +217,38 @@ class PostBloc extends Bloc { /// Event to fetch more comments from a post Future _getPostCommentsEvent(GetPostCommentsEvent event, emit) async { + return _fetchPostComments( + emit, + commentParentId: event.commentParentId, + reset: event.reset, + commentSortType: event.commentSortType, + ); + } + + /// Event to fetch the next root-level comment page from a post. + Future _getPostCommentsPageEvent(GetPostCommentsPageEvent event, emit) async { + return _fetchPostComments(emit); + } + + /// Event to fetch additional replies for an expanded comment. + Future _getPostCommentRepliesEvent(GetPostCommentRepliesEvent event, emit) async { + return _fetchPostComments(emit, commentParentId: event.commentParentId); + } + + Future _fetchPostComments( + Emitter emit, { + int? commentParentId, + bool reset = false, + CommentSortType? commentSortType, + }) async { final platform = account.platform ?? ThreadiversePlatform.lemmy; - final isReplyFetch = event.commentParentId != null; + final isReplyFetch = commentParentId != null; final defaultCommentSortType = CommentSortType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.defaultCommentSortType)?.toLowerCase() ?? DEFAULT_COMMENT_SORT_TYPE.name); - final commentSortType = event.commentSortType ?? (state.commentSortType ?? defaultCommentSortType); + final selectedCommentSortType = commentSortType ?? (state.commentSortType ?? defaultCommentSortType); try { - if (event.reset) { + if (reset) { emit(state.copyWith( status: PostStatus.refreshing, comments: const [], @@ -228,14 +258,14 @@ class PostBloc extends Bloc { commentCursor: null, commentCount: 0, hasReachedCommentEnd: false, - commentSortType: commentSortType, + commentSortType: selectedCommentSortType, )); final response = await commentRepository.getComments( communityId: state.post?.community?.id, - parentId: event.commentParentId, + parentId: commentParentId, postId: state.post!.id, - commentSortType: commentSortType, + commentSortType: selectedCommentSortType, limit: COMMENT_LIMIT, maxDepth: COMMENT_MAX_DEPTH, page: platform == ThreadiversePlatform.lemmy ? 1 : null, @@ -259,7 +289,7 @@ class PostBloc extends Bloc { commentCount: listing.api.length, // If we're intentionally loading a single comment thread, prevent root-level auto pagination. hasReachedCommentEnd: isReplyFetch || nextPage == null, - commentSortType: commentSortType, + commentSortType: selectedCommentSortType, errorReason: null, ), ); @@ -278,17 +308,17 @@ class PostBloc extends Bloc { final response = await commentRepository.getComments( communityId: state.post?.community?.id, postId: state.post!.id, - parentId: event.commentParentId, - commentSortType: commentSortType, + parentId: commentParentId, + commentSortType: selectedCommentSortType, limit: COMMENT_LIMIT, maxDepth: COMMENT_MAX_DEPTH, page: platform == ThreadiversePlatform.lemmy - ? event.commentParentId == null + ? commentParentId == null ? state.commentPage : null : null, cursor: platform == ThreadiversePlatform.piefed - ? event.commentParentId == null + ? commentParentId == null ? state.commentCursor : null : null, @@ -299,8 +329,8 @@ class PostBloc extends Bloc { final int? nextPageNumber = nextPage != null ? int.tryParse(nextPage) : null; // Determine if any one of the results is direct descent of the parent. If not, the UI won't show it, so we should display an error - if (event.commentParentId != null) { - final anyDirectChildren = containsDirectReplyToParent(comments, event.commentParentId!); + if (commentParentId != null) { + final anyDirectChildren = containsDirectReplyToParent(comments, commentParentId); if (!anyDirectChildren) { throw Exception(_localizationService.l10n.unableToLoadReplies); } @@ -311,7 +341,7 @@ class PostBloc extends Bloc { return emit( state.copyWith( status: PostStatus.success, - commentSortType: commentSortType, + commentSortType: selectedCommentSortType, comments: listing.comments, commentNodes: listing.tree, commentResponseMap: listing.api, @@ -346,7 +376,6 @@ class PostBloc extends Bloc { /// Handles comment related actions on a given item within the post Future _commentActionEvent(CommentActionEvent event, Emitter emit) async { - emit(state.copyWith(status: PostStatus.refreshing)); final originalCommentTree = state.commentNodes; if (originalCommentTree == null) { return emit(state.copyWith( @@ -357,8 +386,7 @@ class PostBloc extends Bloc { )); } - final updatedCommentTree = clone(originalCommentTree); - CommentNode? existingCommentNode = updatedCommentTree.search(event.commentId); + final existingCommentNode = originalCommentTree.search(event.commentId); if (existingCommentNode == null) { return emit(state.copyWith( status: PostStatus.failure, @@ -380,16 +408,17 @@ class PostBloc extends Bloc { )); } final value = (event.actionInput as VoteCommentActionInput).score; - CommentNode newCommentNode = CommentNode(comment: optimisticallyVoteComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); - existingCommentNode.insert(newCommentNode); - - // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + final updatedComment = optimisticallyVoteComment(existingCommentNode.comment!, value); + final updatedCommentTree = replaceComment(originalCommentTree, updatedComment); + final updatedComments = updatedCommentTree.flatten(); - await commentRepository.vote(existingCommentNode.comment!, value); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedComments, errorReason: null)); - return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); + final repositoryComment = await commentRepository.vote(existingCommentNode.comment!, value); + if (repositoryComment != updatedComment) { + final serverCommentTree = replaceComment(state.commentNodes ?? updatedCommentTree, repositoryComment); + return emit(state.copyWith(status: PostStatus.success, commentNodes: serverCommentTree, comments: serverCommentTree.flatten(), errorReason: null)); + } } catch (e) { final message = getExceptionErrorMessage(e); return emit(state.copyWith( @@ -414,16 +443,17 @@ class PostBloc extends Bloc { )); } final value = (event.actionInput as SaveCommentActionInput).save; - CommentNode newCommentNode = CommentNode(comment: optimisticallySaveComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); - existingCommentNode.insert(newCommentNode); + final updatedComment = optimisticallySaveComment(existingCommentNode.comment!, value); + final updatedCommentTree = replaceComment(originalCommentTree, updatedComment); + final updatedComments = updatedCommentTree.flatten(); - // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedComments, errorReason: null)); - await commentRepository.save(existingCommentNode.comment!, value); - - return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); + final repositoryComment = await commentRepository.save(existingCommentNode.comment!, value); + if (repositoryComment != updatedComment) { + final serverCommentTree = replaceComment(state.commentNodes ?? updatedCommentTree, repositoryComment); + return emit(state.copyWith(status: PostStatus.success, commentNodes: serverCommentTree, comments: serverCommentTree.flatten(), errorReason: null)); + } } catch (e) { final message = getExceptionErrorMessage(e); return emit(state.copyWith( @@ -448,16 +478,17 @@ class PostBloc extends Bloc { )); } final value = (event.actionInput as DeleteCommentActionInput).delete; - CommentNode newCommentNode = CommentNode(comment: optimisticallyDeleteComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); - existingCommentNode.insert(newCommentNode); + final updatedComment = optimisticallyDeleteComment(existingCommentNode.comment!, value); + final updatedCommentTree = replaceComment(originalCommentTree, updatedComment); + final updatedComments = updatedCommentTree.flatten(); - // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedComments, errorReason: null)); - await commentRepository.delete(existingCommentNode.comment!, value); - - return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); + final repositoryComment = await commentRepository.delete(existingCommentNode.comment!, value); + if (repositoryComment != updatedComment) { + final serverCommentTree = replaceComment(state.commentNodes ?? updatedCommentTree, repositoryComment); + return emit(state.copyWith(status: PostStatus.success, commentNodes: serverCommentTree, comments: serverCommentTree.flatten(), errorReason: null)); + } } catch (e) { final message = getExceptionErrorMessage(e); return emit(state.copyWith( @@ -491,8 +522,6 @@ class PostBloc extends Bloc { ), )); } - emit(state.copyWith(status: PostStatus.refreshing)); - final currentCommentTree = state.commentNodes; if (currentCommentTree == null) { return emit(state.copyWith( @@ -503,8 +532,7 @@ class PostBloc extends Bloc { )); } - final updatedCommentTree = clone(currentCommentTree); - final existingCommentNode = updatedCommentTree.search(event.comment.id); + final existingCommentNode = currentCommentTree.search(event.comment.id); if (existingCommentNode == null) { return emit(state.copyWith( status: PostStatus.failure, @@ -514,12 +542,17 @@ class PostBloc extends Bloc { )); } - existingCommentNode.insert(CommentNode(comment: event.comment, replies: existingCommentNode.replies)); + if (existingCommentNode.comment == event.comment && state.moddingCommentId == -1 && state.errorReason == null) { + return; + } + + final updatedCommentTree = replaceComment(currentCommentTree, event.comment); + final updatedComments = updatedCommentTree.flatten(); return emit(state.copyWith( status: PostStatus.success, commentNodes: updatedCommentTree, - comments: updatedCommentTree.flatten(), + comments: updatedComments, moddingCommentId: -1, errorReason: null, )); @@ -535,7 +568,6 @@ class PostBloc extends Bloc { ), )); } - emit(state.copyWith(status: PostStatus.refreshing)); final updatedCommentTree = clone(currentCommentTree); @@ -549,11 +581,12 @@ class PostBloc extends Bloc { } parentNode.insert(CommentNode(comment: event.comment, replies: [])); + final updatedComments = updatedCommentTree.flatten(); return emit(state.copyWith( status: PostStatus.success, commentNodes: updatedCommentTree, - comments: updatedCommentTree.flatten(), + comments: updatedComments, moddingCommentId: -1, errorReason: null, )); @@ -561,10 +594,9 @@ class PostBloc extends Bloc { Future _reportCommentEvent(ReportCommentEvent event, Emitter emit) async { try { - emit(state.copyWith(status: PostStatus.refreshing, moddingCommentId: event.commentId)); + emit(state.copyWith(moddingCommentId: event.commentId, errorReason: null)); await commentRepository.report(event.commentId, event.message); return emit(state.copyWith( - status: PostStatus.success, moddingCommentId: -1, errorReason: null, )); @@ -588,6 +620,10 @@ class PostBloc extends Bloc { commentId: event.commentId, collapsed: event.collapsed, ); + if (collapsedComments == state.collapsedComments && state.errorReason == null) { + return; + } + return emit(state.copyWith( status: state.status, collapsedComments: collapsedComments, diff --git a/lib/src/features/post/presentation/state/post_event.dart b/lib/src/features/post/presentation/state/post_event.dart index be0bfccc7..ddf05d85e 100644 --- a/lib/src/features/post/presentation/state/post_event.dart +++ b/lib/src/features/post/presentation/state/post_event.dart @@ -65,6 +65,24 @@ class GetPostCommentsEvent extends PostEvent { List get props => [postId, commentParentId, reset, commentSortType]; } +/// Requests the next root-level comment page for the loaded post. +/// +/// This is separated from [GetPostCommentsEvent] so pagination can be dropped +/// while full comment resets remain restartable. +class GetPostCommentsPageEvent extends PostEvent { + const GetPostCommentsPageEvent(); +} + +/// Requests additional replies for a loaded comment subtree. +class GetPostCommentRepliesEvent extends PostEvent { + final int commentParentId; + + const GetPostCommentRepliesEvent({required this.commentParentId}); + + @override + List get props => [commentParentId]; +} + class VotePostEvent extends PostEvent { final int postId; final int score; diff --git a/lib/src/features/post/presentation/state/post_state.dart b/lib/src/features/post/presentation/state/post_state.dart index 7ec5fb311..1d7889863 100644 --- a/lib/src/features/post/presentation/state/post_state.dart +++ b/lib/src/features/post/presentation/state/post_state.dart @@ -30,7 +30,7 @@ class PostState extends Equatable { this.commentSortType, this.selectedCommentPath, this.moddingCommentId = -1, - this.collapsedComments = const [], + this.collapsedComments = const {}, }); /// The current status of the post @@ -64,8 +64,8 @@ class PostState extends Equatable { final String? errorMessage; final AppErrorReason? errorReason; - /// Keeps track of which comments should be collapsed. When a comment is collapsed, its child comments are hidden. - final List collapsedComments; + /// Comment ids whose descendants should be hidden in the comments list. + final Set collapsedComments; PostState copyWith({ PostStatus? status, @@ -85,7 +85,7 @@ class PostState extends Equatable { Object? commentSortType = _postStateUnset, Object? selectedCommentPath = _postStateUnset, int? moddingCommentId, - List? collapsedComments, + Set? collapsedComments, }) { return PostState( status: status ?? this.status, diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body.dart b/lib/src/features/post/presentation/widgets/post_body/post_body.dart index 1ec7db821..b2cb20eac 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body.dart @@ -1,30 +1,22 @@ -// Flutter imports -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -// Package imports import 'package:expandable/expandable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// Project imports import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/theme/color_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; -import 'package:thunder/src/shared/media/media_view.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_content_section.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_flair_section.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_media_section.dart'; import 'package:thunder/src/shared/reply_to_preview_actions.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/packages/ui/ui.dart' show ConditionalParentWidget; /// A widget that displays the body of a post. This includes the title, body, media, and metadata. /// @@ -120,9 +112,14 @@ class _PostBodyState extends State with SingleTickerProviderStateMixin } @override - Widget build(BuildContext context) { - final theme = Theme.of(context); + void dispose() { + expandableController.dispose(); + _selectableRegionFocusNode.dispose(); + super.dispose(); + } + @override + Widget build(BuildContext context) { final hideNsfwPreviews = context.select((cubit) => cubit.state.hideNsfwPreviews); final showCrossPosts = context.select((cubit) => cubit.state.showCrossPosts); final postBodyViewType = context.select((cubit) => cubit.state.postBodyViewType); @@ -143,80 +140,43 @@ class _PostBodyState extends State with SingleTickerProviderStateMixin ), ]; - if (postBodyViewType != PostBodyViewType.condensed && media != null && media.mediaType != MediaType.text) { + if (media != null) { children.add( - Expandable( + PostBodyMediaSection( controller: expandableController, - collapsed: SizedBox.shrink(), - expanded: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: MediaView( - viewMode: ViewMode.comfortable, - media: media, - postId: post.id, - showFullHeightImages: true, - allowUnconstrainedImageHeight: true, - hideNsfwPreviews: hideNsfwPreviews, - ), - ), + post: post, + media: media, + hideNsfwPreviews: hideNsfwPreviews, + postBodyViewType: postBodyViewType, ), ); } if (post.body?.isNotEmpty == true) { children.add( - Expandable( + PostBodyContentSection( controller: expandableController, - collapsed: PostBodyPreview( - post: post, - viewSource: widget.viewSource, - gradientBackgroundColor: widget.showReplyEditorButtons ? getBackgroundColor(context) : null, - onTap: () { - expandableController.toggle(); - setState(() {}); - }, - ), - expanded: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: ConditionalParentWidget( - condition: widget.selectable, - parentBuilder: (child) { - return SelectableRegion( - focusNode: _selectableRegionFocusNode, - // See comments on [SelectableTextModal] regarding the next two properties - selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, - contextMenuBuilder: (context, selectableRegionState) { - return AdaptiveTextSelectionToolbar.buttonItems( - buttonItems: selectableRegionState.contextMenuButtonItems, - anchors: selectableRegionState.contextMenuAnchors, - ); - }, - onSelectionChanged: (value) => widget.onSelectionChanged?.call(value?.plainText), - child: child, - ); - }, - child: widget.viewSource - ? ScalableText( - post.body ?? '', - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - textScaleFactor: contentFontSizeScale.textScaleFactor, - ) - : CommonMarkdownBody(body: post.body ?? '', nsfw: post.nsfw && hideNsfwPreviews), - ), - ), + post: post, + viewSource: widget.viewSource, + selectable: widget.selectable, + showReplyEditorButtons: widget.showReplyEditorButtons, + hideNsfwPreviews: hideNsfwPreviews, + contentFontSizeScale: contentFontSizeScale, + focusNode: _selectableRegionFocusNode, + onSelectionChanged: widget.onSelectionChanged, + onExpand: () { + expandableController.toggle(); + setState(() {}); + }, ), ); } if (post.tags.isNotEmpty) { children.add( - Expandable( + PostBodyFlairSection( controller: expandableController, - collapsed: const SizedBox.shrink(), - expanded: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: PostFlairTags(tags: post.tags), - ), + post: post, ), ); } diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart new file mode 100644 index 000000000..f26549f95 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:expandable/expandable.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; + +/// Displays the expandable markdown or source body for a post. +class PostBodyContentSection extends StatelessWidget { + const PostBodyContentSection({ + super.key, + required this.controller, + required this.post, + required this.viewSource, + required this.selectable, + required this.showReplyEditorButtons, + required this.hideNsfwPreviews, + required this.contentFontSizeScale, + required this.focusNode, + required this.onExpand, + this.onSelectionChanged, + }); + + /// Controller shared with the rest of the expandable post body. + final ExpandableController controller; + + /// Post whose body should be rendered. + final ThunderPost post; + + /// Whether to display the raw markdown source. + final bool viewSource; + + /// Whether the body text should be selectable. + final bool selectable; + + /// Whether reply-editor affordances are visible under this body. + final bool showReplyEditorButtons; + + /// Whether NSFW markdown previews should be obscured. + final bool hideNsfwPreviews; + + /// Font scale used by source text. + final FontScale contentFontSizeScale; + + /// Focus node owned by the parent [PostBody]. + final FocusNode focusNode; + + /// Called when the collapsed preview expands the body. + final VoidCallback onExpand; + + /// Called when selectable text changes. + final void Function(String? selection)? onSelectionChanged; + + @override + Widget build(BuildContext context) { + if (post.body?.isNotEmpty != true) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + + return Expandable( + controller: controller, + collapsed: PostBodyPreview( + post: post, + viewSource: viewSource, + gradientBackgroundColor: showReplyEditorButtons ? getBackgroundColor(context) : null, + onTap: onExpand, + ), + expanded: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: ConditionalParentWidget( + condition: selectable, + parentBuilder: (child) { + return SelectableRegion( + focusNode: focusNode, + // See comments on [SelectableTextModal] regarding the next two properties. + selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, + contextMenuBuilder: (context, selectableRegionState) { + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors, + ); + }, + onSelectionChanged: (value) => onSelectionChanged?.call(value?.plainText), + child: child, + ); + }, + child: viewSource + ? ScalableText( + post.body ?? '', + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + textScaleFactor: contentFontSizeScale.textScaleFactor, + ) + : CommonMarkdownBody(body: post.body ?? '', nsfw: post.nsfw && hideNsfwPreviews), + ), + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_flair_section.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_flair_section.dart new file mode 100644 index 000000000..6df8d9ab2 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_flair_section.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:expandable/expandable.dart'; + +import 'package:thunder/src/features/post/post.dart'; + +/// Displays expandable post flair tags. +class PostBodyFlairSection extends StatelessWidget { + const PostBodyFlairSection({super.key, required this.controller, required this.post}); + + /// Controller shared with the rest of the expandable post body. + final ExpandableController controller; + + /// Post with tags to render. + final ThunderPost post; + + @override + Widget build(BuildContext context) { + if (post.tags.isEmpty) { + return const SizedBox.shrink(); + } + + return Expandable( + controller: controller, + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: PostFlairTags(tags: post.tags), + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_media_section.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_media_section.dart new file mode 100644 index 000000000..c05d65174 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_media_section.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:expandable/expandable.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/media/media_view.dart'; + +/// Displays the expandable media area for a post body. +class PostBodyMediaSection extends StatelessWidget { + const PostBodyMediaSection({super.key, required this.controller, required this.post, required this.media, required this.hideNsfwPreviews, required this.postBodyViewType}); + + /// Controller shared with the rest of the expandable post body. + final ExpandableController controller; + + /// Post that owns [media]. + final ThunderPost post; + + /// Media item to render. + final Media media; + + /// Whether NSFW media previews should be obscured. + final bool hideNsfwPreviews; + + /// Current post body view type. + final PostBodyViewType postBodyViewType; + + @override + Widget build(BuildContext context) { + if (postBodyViewType == PostBodyViewType.condensed || media.mediaType == MediaType.text) { + return const SizedBox.shrink(); + } + + return Expandable( + controller: controller, + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: MediaView( + viewMode: ViewMode.comfortable, + media: media, + postId: post.id, + showFullHeightImages: true, + allowUnconstrainedImageHeight: true, + hideNsfwPreviews: hideNsfwPreviews, + ), + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_body_sliver.dart b/lib/src/features/post/presentation/widgets/post_body_sliver.dart new file mode 100644 index 000000000..df731f6f7 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_body_sliver.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_view_all_comments_banner.dart'; + +/// Sliver that displays the post body and optional highlighted-thread controls. +class PostBodySliver extends StatelessWidget { + const PostBodySliver({ + super.key, + required this.post, + required this.crossPosts, + required this.viewSource, + required this.showCompactPostBody, + }); + + /// Post rendered at the top of the page. + final ThunderPost post; + + /// Related cross-posts displayed by [PostBody]. + final List? crossPosts; + + /// Whether to show the raw post source text. + final bool viewSource; + + /// Whether the body should use the compact highlighted-comment layout. + final bool showCompactPostBody; + + @override + Widget build(BuildContext context) { + return SliverList.list( + children: [ + PostBody( + post: post, + crossPosts: crossPosts, + viewSource: viewSource, + showCompactPostBody: showCompactPostBody, + ), + const PostViewAllCommentsBanner(), + ], + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sliver.dart b/lib/src/features/post/presentation/widgets/post_bottom_sliver.dart new file mode 100644 index 000000000..38a016c62 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_bottom_sliver.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/post/presentation/widgets/post_page_feed_end.dart'; + +/// Sliver that hosts the bottom loading or end-of-comments state. +class PostBottomSliver extends StatelessWidget { + const PostBottomSliver({super.key, required this.appBarKey, required this.postKey}); + + /// Key for the app bar whose height determines the bottom spacer. + final GlobalKey appBarKey; + + /// Stable key segment for resetting footer measurement between posts. + final String postKey; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: PostPageFeedEnd( + key: ValueKey(postKey), + appBarKey: appBarKey, + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_comments_sliver.dart b/lib/src/features/post/presentation/widgets/post_comments_sliver.dart new file mode 100644 index 000000000..9c1813fee --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_comments_sliver.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/post/domain/utils/comment_state_utils.dart'; +import 'package:thunder/src/features/post/post.dart'; + +/// Sliver list that renders post comments with collapsed-thread visibility. +class PostCommentsSliver extends StatelessWidget { + const PostCommentsSliver({super.key, required this.listController}); + + /// Controller used by comment navigation FAB and highlighted-comment scrolling. + final ListController listController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.comments != current.comments || previous.collapsedComments != current.collapsedComments || previous.moddingCommentId != current.moddingCommentId, + builder: (context, state) { + final account = context.read().account; + final highlightedCommentId = context.select((cubit) => cubit.state.highlightedCommentId); + final collapsedCommentIds = state.collapsedComments; + final hiddenIds = hiddenCommentIds( + comments: state.comments, + collapsedCommentIds: collapsedCommentIds, + ); + + return SuperSliverList.builder( + itemCount: state.comments.length + 1, + listController: listController, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + // The navigator FAB uses one-based comment indexes so the first + // comment can still scroll cleanly to the top of the viewport. + return const SizedBox(height: 1); + } + + final commentNode = state.comments[index - 1]; + final comment = commentNode.comment; + if (comment == null) return const SizedBox.shrink(); + + return CommentCard( + key: ValueKey(comment.id), + account: account, + comment: comment, + onCommentUpdated: (comment) { + context.read().add(CommentItemUpdatedEvent(comment: comment)); + context.read().setHighlightedCommentId(null); + }, + onCommentInserted: (comment) { + context.read().add(CommentItemInsertedEvent(comment: comment)); + context.read().setHighlightedCommentId(comment.id); + }, + level: commentNode.depth, + replies: commentNode.replies.length, + collapsed: collapsedCommentIds.contains(comment.id), + hidden: hiddenIds.contains(comment.id), + highlight: comment.id == highlightedCommentId, + onCollapse: (int commentId, bool collapsed) { + context.read().add(UpdateCollapsedComment(commentId: commentId, collapsed: collapsed)); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_fab_overlay.dart b/lib/src/features/post/presentation/widgets/post_fab_overlay.dart new file mode 100644 index 000000000..438a64991 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_fab_overlay.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; + +/// Barrier shown behind the expanded post FAB menu. +class PostFabOverlay extends StatelessWidget { + const PostFabOverlay({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final isFabOpen = context.select((cubit) => cubit.state.isPostFabOpen); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isFabOpen + ? Listener( + onPointerUp: (_) => context.read().setPostFabOpen(false), + child: Container(color: theme.colorScheme.surface.withValues(alpha: 0.95)), + ) + : null, + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart index b0780f4f7..21be5fc31 100644 --- a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart @@ -57,14 +57,14 @@ class PostPageAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final state = context.read().state; + final hideTopBarOnScroll = context.select((cubit) => cubit.state.hideTopBarOnScroll); return SliverAppBar( - pinned: !state.hideTopBarOnScroll, + pinned: !hideTopBarOnScroll, floating: true, centerTitle: false, toolbarHeight: APP_BAR_HEIGHT, - surfaceTintColor: state.hideTopBarOnScroll ? Colors.transparent : null, + surfaceTintColor: hideTopBarOnScroll ? Colors.transparent : null, title: const PostAppBarTitle(), actions: [ PostAppBarActions( @@ -83,15 +83,10 @@ class PostPageAppBar extends StatelessWidget { } } -/// The title of the app bar. This shows the sort type of the comments -class PostAppBarTitle extends StatefulWidget { +/// Title for the post page app bar, including the active comment sort. +class PostAppBarTitle extends StatelessWidget { const PostAppBarTitle({super.key}); - @override - State createState() => _PostAppBarTitleState(); -} - -class _PostAppBarTitleState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -179,7 +174,9 @@ class PostAppBarActions extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final state = context.watch().state; + final state = context.select( + (bloc) => (post: bloc.state.post, commentSortType: bloc.state.commentSortType), + ); return Row( children: [ @@ -279,7 +276,9 @@ class PostAppBarActions extends StatelessWidget { } (String, IconData?) getCommentSort(BuildContext context) { - final state = context.watch().state; + final state = context.select( + (bloc) => (status: bloc.state.status, commentSortType: bloc.state.commentSortType), + ); if (state.status == PostStatus.initial) { return ('', null); diff --git a/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart b/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart new file mode 100644 index 000000000..2d93dbc44 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_body_sliver.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_bottom_sliver.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_comments_sliver.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_page_error.dart'; + +/// Selects the post-page content branch and delegates each branch to slivers. +class PostPageContentSlivers extends StatelessWidget { + const PostPageContentSlivers({ + super.key, + required this.initialPost, + required this.listController, + required this.appBarKey, + required this.viewSource, + required this.showCompactPostBody, + required this.onRetry, + }); + + /// Post shown before the full post response arrives. + final ThunderPost initialPost; + + /// Controller shared with the comment list. + final ListController listController; + + /// Key for measuring the app bar when building the footer spacer. + final GlobalKey appBarKey; + + /// Whether the post body should render source text. + final bool viewSource; + + /// Whether the post body should use compact highlighted-comment layout. + final bool showCompactPostBody; + + /// Called when the user retries a failed post load. + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.status != current.status || previous.post != current.post || previous.crossPosts != current.crossPosts || previous.errorMessage != current.errorMessage, + builder: (context, state) { + if (state.status == PostStatus.initial || state.status == PostStatus.loading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ); + } + + if (state.status == PostStatus.failure) { + return SliverFillRemaining( + hasScrollBody: false, + child: PostPageError(onRetry: onRetry), + ); + } + + final post = state.post ?? initialPost; + return SliverMainAxisGroup( + slivers: [ + PostBodySliver( + post: post, + crossPosts: state.crossPosts, + viewSource: viewSource, + showCompactPostBody: showCompactPostBody, + ), + PostCommentsSliver(listController: listController), + PostBottomSliver( + appBarKey: appBarKey, + postKey: initialPost.apId, + ), + ], + ); + }, + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_page_error.dart b/lib/src/features/post/presentation/widgets/post_page_error.dart new file mode 100644 index 000000000..19b92b442 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_page_error.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/shared/error_message.dart'; + +/// Error state displayed when the post page cannot load its content. +class PostPageError extends StatelessWidget { + const PostPageError({super.key, required this.onRetry}); + + /// Called when the user taps the retry action. + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return Center( + child: ErrorMessage( + title: l10n.unableToLoadPost, + message: l10n.internetOrInstanceIssues, + actions: [ + ( + text: l10n.retry, + action: onRetry, + loading: false, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_page_fab.dart b/lib/src/features/post/presentation/widgets/post_page_fab.dart index 70c9e2b85..1f0552f56 100644 --- a/lib/src/features/post/presentation/widgets/post_page_fab.dart +++ b/lib/src/features/post/presentation/widgets/post_page_fab.dart @@ -20,7 +20,7 @@ import 'package:thunder/src/shared/fabs/comment_navigator_fab.dart'; import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderTypeaheadDialog; /// The FAB for the post page. -class PostPageFAB extends StatefulWidget { +class PostPageFAB extends StatelessWidget { /// The post information - used for various actions. final ThunderPost post; @@ -35,16 +35,6 @@ class PostPageFAB extends StatefulWidget { const PostPageFAB({super.key, required this.post, required this.comments, required this.scrollController, required this.listController}); - @override - State createState() => _PostPageFABState(); -} - -class _PostPageFABState extends State { - @override - void initState() { - super.initState(); - } - void showSortBottomSheet(BuildContext context) { final l10n = GlobalContext.l10n; @@ -60,7 +50,7 @@ class _PostPageFABState extends State { account: account, title: l10n.sortOptions, onSelect: (selected) async { - await widget.scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized); + await scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized); if (context.mounted) { context.read().add(GetPostCommentsEvent(commentSortType: selected.payload, reset: true)); } @@ -172,10 +162,10 @@ class _PostPageFABState extends State { alignment: Alignment.bottomCenter, child: CommentNavigatorFab( initialIndex: 0, - maxIndex: widget.listController.isAttached ? widget.listController.numberOfItems - 1 : 0, - scrollController: widget.scrollController, - listController: widget.listController, - comments: widget.comments, + maxIndex: listController.isAttached ? listController.numberOfItems - 1 : 0, + scrollController: scrollController, + listController: listController, + comments: comments, statusBarHeight: hideTopBarOnScroll ? statusBarHeight : 0, ), ), @@ -191,8 +181,8 @@ class _PostPageFABState extends State { centered: combineNavAndFab, distance: combineNavAndFab ? 45 : 60, icon: Icon( - isSearchInProgress ? Icons.youtube_searched_for_rounded : singlePressAction.getIcon(postLocked: widget.post.locked), - semanticLabel: isSearchInProgress ? l10n.search : singlePressAction.getTitle(context, postLocked: widget.post.locked), + isSearchInProgress ? Icons.youtube_searched_for_rounded : singlePressAction.getIcon(postLocked: post.locked), + semanticLabel: isSearchInProgress ? l10n.search : singlePressAction.getTitle(context, postLocked: post.locked), size: 35, ), onPressed: isSearchInProgress @@ -201,15 +191,15 @@ class _PostPageFABState extends State { } : () => singlePressAction.execute( context: context, - post: widget.post, - postId: widget.post.id, + post: post, + postId: post.id, highlightedCommentId: highlightedCommentId, selectedCommentPath: selectedCommentPath, override: singlePressAction == PostFabAction.backToTop ? () => { - widget.listController.animateToItem( + listController.animateToItem( index: 0, - scrollController: widget.scrollController, + scrollController: scrollController, alignment: 0, duration: (estimatedDistance) => const Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, @@ -218,20 +208,20 @@ class _PostPageFABState extends State { : singlePressAction == PostFabAction.changeSort ? () => showSortBottomSheet(context) : singlePressAction == PostFabAction.replyToPost - ? () => replyToPost(context, widget.post, postLocked: widget.post.locked) + ? () => replyToPost(context, post, postLocked: post.locked) : singlePressAction == PostFabAction.search ? () => startCommentSearch(context) : null), onLongPress: () => longPressAction.execute( context: context, - post: widget.post, - postId: widget.post.id, + post: post, + postId: post.id, selectedCommentPath: selectedCommentPath, override: longPressAction == PostFabAction.backToTop ? () => { - widget.listController.animateToItem( + listController.animateToItem( index: 0, - scrollController: widget.scrollController, + scrollController: scrollController, alignment: 0, duration: (estimatedDistance) => const Duration(milliseconds: 250), curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, @@ -240,7 +230,7 @@ class _PostPageFABState extends State { : longPressAction == PostFabAction.changeSort ? () => showSortBottomSheet(context) : longPressAction == PostFabAction.replyToPost - ? () => replyToPost(context, widget.post, postLocked: widget.post.locked) + ? () => replyToPost(context, post, postLocked: post.locked) : null), fabType: FabType.post, children: [ @@ -256,13 +246,13 @@ class _PostPageFABState extends State { // If we're viewing a specific comment thread, refresh with that context unless "View All Comments" is pressed PostFabAction.refresh.execute( context: context, - postId: widget.post.id, + postId: post.id, selectedCommentPath: selectedCommentPath, ); } else { PostFabAction.refresh.execute( context: context, - postId: widget.post.id, + postId: post.id, ); } }, @@ -276,11 +266,11 @@ class _PostPageFABState extends State { onPressed: () { HapticFeedback.mediumImpact(); PostFabAction.replyToPost.execute( - override: () => replyToPost(context, widget.post, postLocked: widget.post.locked), + override: () => replyToPost(context, post, postLocked: post.locked), ); }, title: PostFabAction.replyToPost.getTitle(context), - icon: Icon(widget.post.locked ? Icons.lock : PostFabAction.replyToPost.getIcon()), + icon: Icon(post.locked ? Icons.lock : PostFabAction.replyToPost.getIcon()), ), if (postFabEnableChangeSort) ActionButton( @@ -298,8 +288,7 @@ class _PostPageFABState extends State { fabType: FabType.post, centered: combineNavAndFab, onPressed: () { - PostFabAction.backToTop - .execute(override: () => {widget.scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized)}); + PostFabAction.backToTop.execute(override: () => {scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized)}); }, title: PostFabAction.backToTop.getTitle(context), icon: Icon(PostFabAction.backToTop.getIcon()), diff --git a/lib/src/features/post/presentation/widgets/post_page_feed_end.dart b/lib/src/features/post/presentation/widgets/post_page_feed_end.dart new file mode 100644 index 000000000..8d2d8e959 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_page_feed_end.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/settings/api.dart'; + +/// Displays the post comment footer and bottom spacer at the end of the list. +class PostPageFeedEnd extends StatefulWidget { + const PostPageFeedEnd({super.key, required this.appBarKey}); + + /// Key for the sliver app bar used to size the bottom spacer. + final GlobalKey appBarKey; + + @override + State createState() => _PostPageFeedEndState(); +} + +class _PostPageFeedEndState extends State { + final GlobalKey _reachedEndKey = GlobalKey(); + double _bottomSpacerHeight = 0.0; + bool _measureScheduled = false; + + void _scheduleSpacerMeasurement() { + if (_measureScheduled) return; + _measureScheduled = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureScheduled = false; + if (!mounted) return; + _measureBottomSpacer(); + }); + } + + void _measureBottomSpacer() { + final deviceHeight = MediaQuery.sizeOf(context).height; + final reachedEndHeight = (_reachedEndKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; + final renderObject = widget.appBarKey.currentContext?.findRenderObject() as RenderSliverFloatingPersistentHeader?; + final appBarHeight = renderObject?.geometry?.maxPaintExtent; + + if (appBarHeight == null || reachedEndHeight == null) return; + + final nextHeight = deviceHeight - appBarHeight - reachedEndHeight; + if ((_bottomSpacerHeight - nextHeight).abs() < 0.5) return; + + setState(() => _bottomSpacerHeight = nextHeight); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + final comments = context.select>((bloc) => bloc.state.comments); + final hasReachedCommentEnd = context.select((bloc) => bloc.state.hasReachedCommentEnd); + final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); + + Widget child = Container( + height: 100.0, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: const CircularProgressIndicator(), + ); + + if (hasReachedCommentEnd) { + _scheduleSpacerMeasurement(); + child = Container( + key: _reachedEndKey, + color: theme.dividerColor.withValues(alpha: 0.1), + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: ScalableText( + comments.isEmpty ? l10n.noCommentsFound : l10n.endOfComments, + textScaleFactor: metadataFontSizeScale.textScaleFactor, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + ); + } + + return Container( + margin: EdgeInsets.only(bottom: _bottomSpacerHeight), + child: child, + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_page_floating_action_button.dart b/lib/src/features/post/presentation/widgets/post_page_floating_action_button.dart new file mode 100644 index 000000000..0dda628cc --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_page_floating_action_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/post/post.dart'; + +/// Selects only the post and comments required by the post page FAB. +class PostPageFloatingActionButton extends StatelessWidget { + const PostPageFloatingActionButton({ + super.key, + required this.initialPost, + required this.scrollController, + required this.listController, + }); + + /// Fallback post displayed before the bloc receives the loaded post. + final ThunderPost initialPost; + + /// Scroll controller used by FAB actions. + final ScrollController scrollController; + + /// Comment-list controller used by comment navigation actions. + final ListController listController; + + @override + Widget build(BuildContext context) { + final fabData = context.select comments})>( + (bloc) => (post: bloc.state.post, comments: bloc.state.comments), + ); + + return PostPageFAB( + post: fabData.post ?? initialPost, + comments: fabData.comments, + scrollController: scrollController, + listController: listController, + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_page_scroll_body.dart b/lib/src/features/post/presentation/widgets/post_page_scroll_body.dart new file mode 100644 index 000000000..ffc00cf00 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_page_scroll_body.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_page_content_slivers.dart'; +import 'package:thunder/src/shared/text/selectable_text_modal.dart'; + +/// Scrollable sliver body for the post page. +class PostPageScrollBody extends StatelessWidget { + const PostPageScrollBody({ + super.key, + required this.scrollController, + required this.listController, + required this.appBarKey, + required this.initialPost, + required this.viewSource, + required this.onViewSource, + required this.onReset, + required this.onRetry, + this.highlightedCommentId, + this.commentPath, + }); + + /// Scroll controller shared by refresh, app bar reset, and FAB actions. + final ScrollController scrollController; + + /// Controller shared by the comment list and comment navigator. + final ListController listController; + + /// Key used by the footer to measure the app bar height. + final GlobalKey appBarKey; + + /// Post shown while the bloc resolves the full post payload. + final ThunderPost initialPost; + + /// Whether to show post source text. + final bool viewSource; + + /// Called when the user toggles post-source mode. + final ValueChanged onViewSource; + + /// Scrolls the page to the top before refresh or sort actions. + final Future Function() onReset; + + /// Called when the post error state retries loading. + final VoidCallback onRetry; + + /// Initially highlighted comment id. + final int? highlightedCommentId; + + /// Initially highlighted comment path. + final String? commentPath; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + controller: scrollController, + cacheExtent: 1000, + slivers: [ + PostPageAppBar( + key: appBarKey, + viewSource: viewSource, + onViewSource: onViewSource, + onReset: onReset, + onCreateCrossPost: () { + final post = context.read().state.post ?? initialPost; + createCrossPost( + context, + title: post.name, + url: post.url, + text: post.body, + postUrl: post.apId, + ); + }, + onSelectText: () { + final post = context.read().state.post ?? initialPost; + showSelectableTextModal( + context, + title: post.name, + text: post.body ?? '', + ); + }, + onPostChanged: (post) => context.read().add(GetPostEvent(post: post)), + highlightedCommentId: highlightedCommentId, + commentPath: commentPath, + ), + PostPageContentSlivers( + initialPost: initialPost, + listController: listController, + appBarKey: appBarKey, + viewSource: viewSource, + showCompactPostBody: highlightedCommentId != null, + onRetry: onRetry, + ), + ], + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart b/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart new file mode 100644 index 000000000..a1405acb2 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; + +/// Covers the status-bar area when the top app bar scrolls away. +class PostTopBarScrim extends StatelessWidget { + const PostTopBarScrim({super.key}); + + @override + Widget build(BuildContext context) { + final hideTopBarOnScroll = context.select((cubit) => cubit.state.hideTopBarOnScroll); + if (!hideTopBarOnScroll) return const SizedBox.shrink(); + + final theme = Theme.of(context); + + return Positioned( + child: Container( + height: MediaQuery.paddingOf(context).top, + color: theme.colorScheme.surface, + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/post_view_all_comments_banner.dart b/lib/src/features/post/presentation/widgets/post_view_all_comments_banner.dart new file mode 100644 index 000000000..6f52e2a7c --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_view_all_comments_banner.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/post/post.dart'; + +/// Banner that exits a highlighted comment thread and reloads all comments. +class PostViewAllCommentsBanner extends StatelessWidget { + const PostViewAllCommentsBanner({super.key}); + + @override + Widget build(BuildContext context) { + final highlightedCommentId = context.select((cubit) => cubit.state.highlightedCommentId); + if (highlightedCommentId == null) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return InkWell( + onTap: () { + context.read().add(const GetPostCommentsEvent(reset: true, commentParentId: null)); + context.read().setHighlightedCommentId(null); + }, + child: Container( + height: 60.0, + decoration: BoxDecoration(border: Border(top: BorderSide(color: theme.dividerColor))), + child: Row( + spacing: 4.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.viewAllComments, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500)), + const Icon(Icons.arrow_right_alt_rounded), + ], + ), + ), + ); + } +}