From 30a450418f166465bf4bfb286b1e8b4d14058341 Mon Sep 17 00:00:00 2001 From: ajsosa Date: Mon, 17 Jul 2023 13:13:41 -0500 Subject: [PATCH] Initial support for viewing comment context when clicking on a comment from a user's comment list. Also adds button for loading full comments afterwards. Pull down refresh when viewing context will refresh the same context instead of doing a full load. --- lib/post/bloc/post_bloc.dart | 34 ++++-- lib/post/bloc/post_event.dart | 7 +- lib/post/bloc/post_state.dart | 21 +++- lib/post/pages/post_page.dart | 17 +-- lib/post/pages/post_page_success.dart | 13 +++ lib/post/widgets/comment_card.dart | 6 ++ lib/post/widgets/comment_view.dart | 148 +++++++++++++++++++------- lib/post/widgets/post_view.dart | 6 +- lib/user/widgets/comment_card.dart | 2 +- 9 files changed, 189 insertions(+), 65 deletions(-) diff --git a/lib/post/bloc/post_bloc.dart b/lib/post/bloc/post_bloc.dart index 0c989594c..95d452aa8 100644 --- a/lib/post/bloc/post_bloc.dart +++ b/lib/post/bloc/post_bloc.dart @@ -113,9 +113,14 @@ class PostBloc extends Bloc { CommentSortType sortType = event.sortType ?? (state.sortType ?? defaultSortType); + int? parentId ; + if (event.selectedCommentPath != null) { + parentId = int.parse(event.selectedCommentPath!.split('.')[1]); + } + List getCommentsResponse = await lemmy .run(GetComments( - page: 1, + page: event.selectedCommentId == null ? 1 : null, auth: account?.jwt, communityId: postView?.postView.post.communityId, maxDepth: COMMENT_MAX_DEPTH, @@ -123,9 +128,11 @@ class PostBloc extends Bloc { sort: sortType, limit: commentLimit, type: CommentListingType.all, + parentId: parentId, )) .timeout(timeout, onTimeout: () { - throw Exception('Error: Timeout when attempting to fetch comments'); + throw Exception( + 'Error: Timeout when attempting to fetch comments'); }); // Build the tree view from the flattened comments @@ -138,11 +145,13 @@ class PostBloc extends Bloc { postView: postView, comments: commentTree, commentResponseList: getCommentsResponse, - commentPage: state.commentPage + 1, + commentPage: state.commentPage + (event.selectedCommentId == null ? 1 : 0), commentCount: getCommentsResponse.length, hasReachedCommentEnd: getCommentsResponse.isEmpty || getCommentsResponse.length < commentLimit, communityId: postView?.postView.post.communityId, - sortType: sortType), + sortType: sortType, + selectedCommentId: event.selectedCommentId, + selectedCommentPath: event.selectedCommentPath), ); } catch (e, s) { exception = e; @@ -173,8 +182,12 @@ class PostBloc extends Bloc { try { LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; - if (event.reset) { - emit(state.copyWith(status: PostStatus.loading)); + if (event.reset || event.viewAllCommentsRefresh) { + if(event.viewAllCommentsRefresh) { + emit(state.copyWith(status: PostStatus.refreshing, selectedCommentId: state.selectedCommentId, viewAllCommentsRefresh: true, sortType: sortType)); + } else { + emit(state.copyWith(status: PostStatus.loading, sortType: sortType)); + } List getCommentsResponse = await lemmy .run(GetComments( @@ -197,6 +210,8 @@ class PostBloc extends Bloc { return emit( state.copyWith( + selectedCommentId: null, + selectedCommentPath: null, status: PostStatus.success, comments: commentTree, commentResponseList: getCommentsResponse, @@ -220,7 +235,7 @@ class PostBloc extends Bloc { sort: sortType, limit: commentLimit, maxDepth: COMMENT_MAX_DEPTH, - page: event.commentParentId != null ? 1 : state.commentPage, + page: state.commentPage,//event.commentParentId != null ? 1 : state.commentPage, type: CommentListingType.all, )) .timeout(timeout, onTimeout: () { @@ -235,12 +250,15 @@ class PostBloc extends Bloc { // We'll add in a edge case here to stop fetching comments after theres no more comments to be fetched return emit(state.copyWith( + sortType: sortType, status: PostStatus.success, + selectedCommentPath: null, + selectedCommentId: null, comments: commentViewTree, commentResponseList: fullCommentResponseList, commentPage: event.commentParentId != null ? 1 : state.commentPage + 1, commentCount: fullCommentResponseList.length, - hasReachedCommentEnd: event.commentParentId != null && (getCommentsResponse.isEmpty || getCommentsResponse.length < commentLimit), + hasReachedCommentEnd: event.commentParentId != null || (getCommentsResponse.isEmpty || getCommentsResponse.length < commentLimit), )); } catch (e, s) { exception = e; diff --git a/lib/post/bloc/post_event.dart b/lib/post/bloc/post_event.dart index 9b01da1ab..4da4d6608 100644 --- a/lib/post/bloc/post_event.dart +++ b/lib/post/bloc/post_event.dart @@ -11,17 +11,20 @@ class GetPostEvent extends PostEvent { final int? postId; final PostViewMedia? postView; final CommentSortType? sortType; + final String? selectedCommentPath; + final int? selectedCommentId; - const GetPostEvent({this.sortType, this.postView, this.postId}); + const GetPostEvent({this.sortType, this.postView, this.postId, this.selectedCommentPath, this.selectedCommentId}); } class GetPostCommentsEvent extends PostEvent { final int? postId; final int? commentParentId; final bool reset; + final bool viewAllCommentsRefresh; final CommentSortType? sortType; - const GetPostCommentsEvent({this.postId, this.commentParentId, this.reset = false, this.sortType}); + const GetPostCommentsEvent({this.postId, this.commentParentId, this.reset = false, this.viewAllCommentsRefresh = false, this.sortType}); } class VotePostEvent extends PostEvent { diff --git a/lib/post/bloc/post_state.dart b/lib/post/bloc/post_state.dart index 07eb8a0c9..b7b05ef18 100644 --- a/lib/post/bloc/post_state.dart +++ b/lib/post/bloc/post_state.dart @@ -15,10 +15,15 @@ class PostState extends Equatable { this.hasReachedCommentEnd = false, this.errorMessage, this.sortType, - this.sortTypeIcon}); + this.sortTypeIcon, + this.selectedCommentId, + this.selectedCommentPath, + this.viewAllCommentsRefresh = false}); final PostStatus status; + final bool viewAllCommentsRefresh; + final CommentSortType? sortType; final IconData? sortTypeIcon; @@ -32,6 +37,8 @@ class PostState extends Equatable { final int commentPage; final int commentCount; final bool hasReachedCommentEnd; + final int? selectedCommentId; + final String? selectedCommentPath; final String? errorMessage; @@ -48,6 +55,9 @@ class PostState extends Equatable { String? errorMessage, CommentSortType? sortType, IconData? sortTypeIcon, + int? selectedCommentId, + String? selectedCommentPath, + bool? viewAllCommentsRefresh = false, }) { return PostState( status: status, @@ -60,11 +70,14 @@ class PostState extends Equatable { hasReachedCommentEnd: hasReachedCommentEnd ?? this.hasReachedCommentEnd, communityId: communityId ?? this.communityId, errorMessage: errorMessage ?? this.errorMessage, - sortType: sortType, - sortTypeIcon: sortTypeIcon, + sortType: sortType ?? this.sortType, + sortTypeIcon: sortTypeIcon ?? this.sortTypeIcon, + selectedCommentId: selectedCommentId, + selectedCommentPath: selectedCommentPath, + viewAllCommentsRefresh: viewAllCommentsRefresh ?? false, ); } @override - List get props => [status, postId, postView, comments, commentPage, commentCount, communityId, errorMessage, hasReachedCommentEnd, sortType, sortTypeIcon]; + List get props => [status, postId, postView, comments, commentPage, commentCount, communityId, errorMessage, hasReachedCommentEnd, sortType, sortTypeIcon, selectedCommentId, selectedCommentPath, viewAllCommentsRefresh]; } diff --git a/lib/post/pages/post_page.dart b/lib/post/pages/post_page.dart index 3092a861f..bd07e5bf8 100644 --- a/lib/post/pages/post_page.dart +++ b/lib/post/pages/post_page.dart @@ -4,7 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; -import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/models/post_view_media.dart'; import 'package:thunder/post/bloc/post_bloc.dart'; import 'package:thunder/post/pages/post_page_success.dart'; @@ -12,15 +11,16 @@ import 'package:thunder/post/widgets/create_comment_modal.dart'; import 'package:thunder/shared/comment_sort_picker.dart'; import 'package:thunder/shared/error_message.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; -import 'package:thunder/thunder/thunder.dart'; class PostPage extends StatefulWidget { final PostViewMedia? postView; final int? postId; + final String? selectedCommentPath; + final int? selectedCommentId; final VoidCallback onPostUpdated; - const PostPage({super.key, this.postView, this.postId, required this.onPostUpdated}); + const PostPage({super.key, this.postView, this.postId, this.selectedCommentPath, this.selectedCommentId, required this.onPostUpdated}); @override State createState() => _PostPageState(); @@ -64,7 +64,6 @@ class _PostPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isUserLoggedIn = context.read().state.isLoggedIn; return BlocProvider( create: (context) => PostBloc(), @@ -192,7 +191,6 @@ class _PostPageState extends State { backgroundColor: theme.colorScheme.onErrorContainer, behavior: SnackBarBehavior.floating, ); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -201,7 +199,8 @@ class _PostPageState extends State { } switch (state.status) { case PostStatus.initial: - context.read().add(GetPostEvent(postView: widget.postView, postId: widget.postId)); + context.read().add(GetPostEvent(postView: widget.postView, postId: widget.postId, + selectedCommentPath: widget.selectedCommentPath, selectedCommentId: widget.selectedCommentId)); return const Center(child: CircularProgressIndicator()); case PostStatus.loading: return const Center(child: CircularProgressIndicator()); @@ -212,9 +211,11 @@ class _PostPageState extends State { return RefreshIndicator( onRefresh: () async { HapticFeedback.mediumImpact(); - return context.read().add(GetPostEvent(postView: widget.postView, postId: widget.postId)); + return context.read().add(GetPostEvent(postView: widget.postView, postId: widget.postId, + selectedCommentId: state.selectedCommentId, selectedCommentPath: state.selectedCommentPath)); }, - child: PostPageSuccess(postView: state.postView!, comments: state.comments, scrollController: _scrollController, hasReachedCommentEnd: state.hasReachedCommentEnd), + child: PostPageSuccess(postView: state.postView!, comments: state.comments, selectedCommentId: state.selectedCommentId, + viewFullCommentsRefreshing: state.viewAllCommentsRefresh, scrollController: _scrollController, hasReachedCommentEnd: state.hasReachedCommentEnd), ); } return ErrorMessage( diff --git a/lib/post/pages/post_page_success.dart b/lib/post/pages/post_page_success.dart index 568a8fddc..c4a258417 100644 --- a/lib/post/pages/post_page_success.dart +++ b/lib/post/pages/post_page_success.dart @@ -11,16 +11,21 @@ import 'package:thunder/post/widgets/comment_view.dart'; class PostPageSuccess extends StatefulWidget { final PostViewMedia postView; final List comments; + final int? selectedCommentId; final ScrollController scrollController; final bool hasReachedCommentEnd; + final bool viewFullCommentsRefreshing; + const PostPageSuccess({ super.key, required this.postView, this.comments = const [], required this.scrollController, this.hasReachedCommentEnd = false, + this.selectedCommentId, + this.viewFullCommentsRefreshing = false, }); @override @@ -41,6 +46,12 @@ class _PostPageSuccessState extends State { } void _onScroll() { + // We don't want to trigger comment fetch when looking at a comment context. + // This also fixes a weird behavior that can happen when if the fetch triggers + // right before you click view all comments. The fetch for all comments won't happen. + if (widget.selectedCommentId != null) { + return; + } if (widget.scrollController.position.pixels >= widget.scrollController.position.maxScrollExtent * 0.6) { context.read().add(const GetPostCommentsEvent()); } @@ -52,6 +63,8 @@ class _PostPageSuccessState extends State { children: [ Expanded( child: CommentSubview( + viewFullCommentsRefreshing: widget.viewFullCommentsRefreshing, + selectedCommentId: widget.selectedCommentId, scrollController: widget.scrollController, postViewMedia: widget.postView, comments: widget.comments, diff --git a/lib/post/widgets/comment_card.dart b/lib/post/widgets/comment_card.dart index 7f9ee4889..27a3f4b3c 100644 --- a/lib/post/widgets/comment_card.dart +++ b/lib/post/widgets/comment_card.dart @@ -22,6 +22,7 @@ class CommentCard extends StatefulWidget { final Function(int, bool) onCollapseCommentChange; final Set collapsedCommentSet; + final int? selectCommentId; const CommentCard({ super.key, @@ -32,6 +33,7 @@ class CommentCard extends StatefulWidget { required this.onSaveAction, required this.onCollapseCommentChange, this.collapsedCommentSet = const {}, + this.selectCommentId, }); /// CommentViewTree containing relevant information @@ -125,6 +127,9 @@ class _CommentCardState extends State with SingleTickerProviderStat return Container( decoration: BoxDecoration( + color: widget.selectCommentId == widget.commentViewTree.commentView!.comment.id + ? theme.highlightColor + : theme.colorScheme.background, border: widget.level > 0 ? Border( left: BorderSide( @@ -372,6 +377,7 @@ class _CommentCardState extends State with SingleTickerProviderStat shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) => CommentCard( + selectCommentId: widget.selectCommentId, commentViewTree: widget.commentViewTree.replies[index], collapsedCommentSet: widget.collapsedCommentSet, collapsed: widget.collapsedCommentSet.contains(widget.commentViewTree.replies[index].commentView!.comment.id), diff --git a/lib/post/widgets/comment_view.dart b/lib/post/widgets/comment_view.dart index dd20a0a93..1dd2b465a 100644 --- a/lib/post/widgets/comment_view.dart +++ b/lib/post/widgets/comment_view.dart @@ -10,6 +10,8 @@ import 'package:thunder/core/models/comment_view_tree.dart'; import 'package:thunder/post/widgets/post_view.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import '../bloc/post_bloc.dart'; + class CommentSubview extends StatefulWidget { final List comments; final int level; @@ -18,9 +20,11 @@ class CommentSubview extends StatefulWidget { final Function(int, bool) onSaveAction; final PostViewMedia? postViewMedia; + final int? selectedCommentId; final ScrollController? scrollController; final bool hasReachedCommentEnd; + final bool viewFullCommentsRefreshing; const CommentSubview({ super.key, @@ -29,56 +33,64 @@ class CommentSubview extends StatefulWidget { required this.onVoteAction, required this.onSaveAction, this.postViewMedia, + this.selectedCommentId, this.scrollController, this.hasReachedCommentEnd = false, + this.viewFullCommentsRefreshing = false, }); @override State createState() => _CommentSubviewState(); } -class _CommentSubviewState extends State { +class _CommentSubviewState extends State with SingleTickerProviderStateMixin { Set collapsedCommentSet = {}; // Retains the collapsed state of any comments + bool _animatingOut = false; + bool _animatingIn = false; + bool _removeViewFullCommentsButton = false; + + late final AnimationController _fullCommentsAnimation = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + late final Animation _fullCommentsOffsetAnimation = Tween( + begin: Offset.zero, + end: const Offset(0.0, 5), + ).animate(CurvedAnimation( + parent: _fullCommentsAnimation, + curve: Curves.easeInOut, + )); + + @override + void initState() { + super.initState(); + _fullCommentsOffsetAnimation.addStatusListener((status) { + if(status == AnimationStatus.completed && _animatingOut) { + _animatingOut = false; + _removeViewFullCommentsButton = true; + context.read().add(const GetPostCommentsEvent(commentParentId: null, viewAllCommentsRefresh: true)); + } + }); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final ThunderState state = context.read().state; + if(!widget.viewFullCommentsRefreshing && _removeViewFullCommentsButton) { + _animatingIn = true; + _fullCommentsAnimation.reverse(); + } + return ListView.builder( addSemanticIndexes: false, controller: widget.scrollController, itemCount: getCommentsListLength(), itemBuilder: (context, index) { if (widget.postViewMedia != null && index == 0) { - return PostSubview(useDisplayNames: state.useDisplayNames, postViewMedia: widget.postViewMedia!); - } else if (widget.hasReachedCommentEnd == false && widget.comments.isEmpty) { - return Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: const CircularProgressIndicator(), - ), - ], - ); - } else if (index == widget.comments.length + 1) { - if (widget.hasReachedCommentEnd == true) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - color: theme.dividerColor.withOpacity(0.1), - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: Text( - 'Hmmm. It seems like you\'ve reached the bottom.', - textScaleFactor: state.contentFontSizeScale.textScaleFactor, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - ), - ), - ], - ); - } else { + return PostSubview(selectedCommentId: widget.selectedCommentId, useDisplayNames: state.useDisplayNames, postViewMedia: widget.postViewMedia!); + } if (widget.hasReachedCommentEnd == false && widget.comments.isEmpty) { return Column( children: [ Container( @@ -87,18 +99,76 @@ class _CommentSubviewState extends State { ), ], ); - } - } else { - return CommentCard( - commentViewTree: widget.comments[index - 1], - collapsedCommentSet: collapsedCommentSet, - collapsed: collapsedCommentSet.contains(widget.comments[index - 1].commentView!.comment.id) || widget.level == 2, - onSaveAction: (int commentId, bool save) => widget.onSaveAction(commentId, save), - onVoteAction: (int commentId, VoteType voteType) => widget.onVoteAction(commentId, voteType), - onCollapseCommentChange: (int commentId, bool collapsed) => onCollapseCommentChange(commentId, collapsed), + } else { + return SlideTransition( + position: _fullCommentsOffsetAnimation, + child: Column( + children: [ + if (widget.selectedCommentId != null && !_animatingIn && index != widget.comments.length + 1) + Center( + child: Column( + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: theme.colorScheme.primaryContainer, + textStyle: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + onPressed: () { + _animatingOut = true; + _fullCommentsAnimation.forward(); + }, + child: const Text('View all comments'), + ), + const Padding(padding: EdgeInsets.only(top: 10)), + ] + ) + ), + if (index != widget.comments.length + 1) + CommentCard( + selectCommentId: widget.selectedCommentId, + commentViewTree: widget.comments[index - 1], + collapsedCommentSet: collapsedCommentSet, + collapsed: collapsedCommentSet.contains(widget.comments[index - 1].commentView!.comment.id) || widget.level == 2, + onSaveAction: (int commentId, bool save) => widget.onSaveAction(commentId, save), + onVoteAction: (int commentId, VoteType voteType) => widget.onVoteAction(commentId, voteType), + onCollapseCommentChange: (int commentId, bool collapsed) => onCollapseCommentChange(commentId, collapsed) + ), + if (index == widget.comments.length + 1) ...[ + if (widget.hasReachedCommentEnd == true) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: theme.dividerColor.withOpacity(0.1), + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Text( + 'Hmmm. It seems like you\'ve reached the bottom.', + textScaleFactor: state.contentFontSizeScale.textScaleFactor, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + ), + ], + ) + ] else ...[ + Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: const CircularProgressIndicator(), + ), + ], + ) + ] + ] + ] + ) ); } - }, + } ); } diff --git a/lib/post/widgets/post_view.dart b/lib/post/widgets/post_view.dart index 52f042a08..aaac94f02 100644 --- a/lib/post/widgets/post_view.dart +++ b/lib/post/widgets/post_view.dart @@ -20,13 +20,13 @@ import 'package:thunder/shared/media_view.dart'; import 'package:thunder/user/pages/user_page.dart'; import 'package:thunder/utils/instance.dart'; import 'package:thunder/utils/numbers.dart'; -import '../../utils/date_time.dart'; class PostSubview extends StatelessWidget { final PostViewMedia postViewMedia; final bool useDisplayNames; + final int? selectedCommentId; - const PostSubview({super.key, required this.useDisplayNames, required this.postViewMedia}); + const PostSubview({super.key, this.selectedCommentId, required this.useDisplayNames, required this.postViewMedia}); @override Widget build(BuildContext context) { @@ -298,7 +298,7 @@ class PostSubview extends StatelessWidget { ), ) ], - ) + ), ], ), ); diff --git a/lib/user/widgets/comment_card.dart b/lib/user/widgets/comment_card.dart index 57b2d9077..463250278 100644 --- a/lib/user/widgets/comment_card.dart +++ b/lib/user/widgets/comment_card.dart @@ -38,7 +38,7 @@ class CommentCard extends StatelessWidget { BlocProvider.value(value: thunderBloc), BlocProvider(create: (context) => PostBloc()), ], - child: PostPage(postId: comment.post.id, onPostUpdated: () => {}), + child: PostPage(postId: comment.post.id, selectedCommentPath: comment.comment.path, selectedCommentId: comment.comment.id, onPostUpdated: () => {}), ), ), );