feat(comments): add long-press to collapse comment threads

- Add collapsed comment tracking to CommentsProvider with toggleCollapsed()
- Add long-press gesture on CommentCard with haptic feedback
- Show "+N hidden" badge when thread is collapsed (depth-aware positioning)
- Animate collapse/expand with AnimatedSwitcher + SizeTransition (200ms)
- Only build replies widget when not collapsed (optimization)
- Wire up collapse state in PostDetailScreen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+223 -81
lib
+21
lib/providers/comments_provider.dart
···
String? _cursor;
bool _hasMore = true;
// Current post being viewed
String? _postUri;
String? _postCid;
···
String get sort => _sort;
String? get timeframe => _timeframe;
ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
/// Start periodic time updates for "time ago" strings
///
···
_postUri = null;
_postCid = null;
_pendingRefresh = false;
notifyListeners();
}
···
String? _cursor;
bool _hasMore = true;
+
// Collapsed thread state - stores URIs of collapsed comments
+
final Set<String> _collapsedComments = {};
+
// Current post being viewed
String? _postUri;
String? _postCid;
···
String get sort => _sort;
String? get timeframe => _timeframe;
ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
+
Set<String> get collapsedComments => _collapsedComments;
+
+
/// Toggle collapsed state for a comment thread
+
///
+
/// When collapsed, the comment's replies are hidden from view.
+
/// Long-pressing the same comment again will expand the thread.
+
void toggleCollapsed(String uri) {
+
if (_collapsedComments.contains(uri)) {
+
_collapsedComments.remove(uri);
+
} else {
+
_collapsedComments.add(uri);
+
}
+
notifyListeners();
+
}
+
+
/// Check if a specific comment is collapsed
+
bool isCollapsed(String uri) => _collapsedComments.contains(uri);
/// Start periodic time updates for "time ago" strings
///
···
_postUri = null;
_postCid = null;
_pendingRefresh = false;
+
_collapsedComments.clear();
notifyListeners();
}
+8
lib/screens/home/post_detail_screen.dart
···
currentTimeNotifier:
commentsProvider.currentTimeNotifier,
onCommentTap: _openReplyToComment,
);
},
childCount:
···
required this.comment,
required this.currentTimeNotifier,
this.onCommentTap,
});
final ThreadViewComment comment;
final ValueNotifier<DateTime?> currentTimeNotifier;
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
···
currentTime: currentTime,
maxDepth: 6,
onCommentTap: onCommentTap,
);
},
);
···
currentTimeNotifier:
commentsProvider.currentTimeNotifier,
onCommentTap: _openReplyToComment,
+
collapsedComments: commentsProvider.collapsedComments,
+
onCollapseToggle: commentsProvider.toggleCollapsed,
);
},
childCount:
···
required this.comment,
required this.currentTimeNotifier,
this.onCommentTap,
+
this.collapsedComments = const {},
+
this.onCollapseToggle,
});
final ThreadViewComment comment;
final ValueNotifier<DateTime?> currentTimeNotifier;
final void Function(ThreadViewComment)? onCommentTap;
+
final Set<String> collapsedComments;
+
final void Function(String uri)? onCollapseToggle;
@override
Widget build(BuildContext context) {
···
currentTime: currentTime,
maxDepth: 6,
onCommentTap: onCommentTap,
+
collapsedComments: collapsedComments,
+
onCollapseToggle: onCollapseToggle,
);
},
);
+117 -67
lib/widgets/comment_card.dart
···
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
/// - Tap-to-reply functionality via [onTap] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
class CommentCard extends StatelessWidget {
const CommentCard({
required this.comment,
this.depth = 0,
this.currentTime,
this.onTap,
super.key,
});
···
/// Callback when the comment is tapped (for reply functionality)
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
// All comments get at least 1 threading line (depth + 1)
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
-
return InkWell(
-
onTap: onTap,
-
child: Container(
-
decoration: const BoxDecoration(color: AppColors.background),
-
child: Stack(
-
children: [
-
// Threading indicators - vertical lines showing nesting ancestry
-
Positioned.fill(
-
child: CustomPaint(
-
painter: _CommentDepthPainter(depth: threadingLineCount),
),
-
),
-
// Bottom border (starts after threading lines, not overlapping them)
-
Positioned(
-
left: borderLeftOffset,
-
right: 0,
-
bottom: 0,
-
child: Container(height: 1, color: AppColors.border),
-
),
-
// Comment content with depth-based left padding
-
Padding(
-
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author info row
-
Row(
-
children: [
-
// Author avatar
-
_buildAuthorAvatar(comment.author),
-
const SizedBox(width: 8),
-
Expanded(
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author handle
-
Text(
-
'@${comment.author.handle}',
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(
-
alpha: 0.5,
),
-
fontSize: 13,
-
fontWeight: FontWeight.w500,
),
-
),
-
],
-
),
-
),
-
// Time ago
-
Text(
-
DateTimeUtils.formatTimeAgo(
-
comment.createdAt,
-
currentTime: currentTime,
),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.5),
-
fontSize: 12,
),
-
),
],
-
),
-
const SizedBox(height: 8),
-
// Comment content
-
if (comment.content.isNotEmpty) ...[
-
_buildCommentContent(comment),
-
const SizedBox(height: 8),
],
-
-
// Action buttons (just vote for now)
-
_buildActionButtons(context),
-
],
),
-
),
-
],
),
),
);
···
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
/// - Tap-to-reply functionality via [onTap] callback
+
/// - Long-press to collapse thread via [onLongPress] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
+
///
+
/// When [isCollapsed] is true, displays a badge showing [collapsedCount]
+
/// hidden replies on the threading indicator bar.
class CommentCard extends StatelessWidget {
const CommentCard({
required this.comment,
this.depth = 0,
this.currentTime,
this.onTap,
+
this.onLongPress,
+
this.isCollapsed = false,
+
this.collapsedCount = 0,
super.key,
});
···
/// Callback when the comment is tapped (for reply functionality)
final VoidCallback? onTap;
+
/// Callback when the comment is long-pressed (for collapse functionality)
+
final VoidCallback? onLongPress;
+
+
/// Whether this comment's thread is currently collapsed
+
final bool isCollapsed;
+
+
/// Number of replies hidden when collapsed
+
final int collapsedCount;
+
@override
Widget build(BuildContext context) {
// All comments get at least 1 threading line (depth + 1)
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
+
return GestureDetector(
+
onLongPress: onLongPress != null
+
? () {
+
HapticFeedback.mediumImpact();
+
onLongPress!();
+
}
+
: null,
+
child: InkWell(
+
onTap: onTap,
+
child: Container(
+
decoration: const BoxDecoration(color: AppColors.background),
+
child: Stack(
+
children: [
+
// Threading indicators - vertical lines showing nesting ancestry
+
Positioned.fill(
+
child: CustomPaint(
+
painter: _CommentDepthPainter(depth: threadingLineCount),
+
),
),
+
// Collapsed count badge - positioned after threading lines
+
// to avoid overlap at any depth level
+
if (isCollapsed && collapsedCount > 0)
+
Positioned(
+
left: borderLeftOffset + 4,
+
bottom: 8,
+
child: Container(
+
padding: const EdgeInsets.symmetric(
+
horizontal: 6,
+
vertical: 2,
+
),
+
decoration: BoxDecoration(
+
color: AppColors.primary,
+
borderRadius: BorderRadius.circular(8),
+
),
+
child: Text(
+
'+$collapsedCount hidden',
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 10,
+
fontWeight: FontWeight.w500,
+
),
+
),
+
),
+
),
+
// Bottom border
+
// (starts after threading lines, not overlapping them)
+
Positioned(
+
left: borderLeftOffset,
+
right: 0,
+
bottom: 0,
+
child: Container(height: 1, color: AppColors.border),
+
),
+
// Comment content with depth-based left padding
+
Padding(
+
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author info row
+
Row(
+
children: [
+
// Author avatar
+
_buildAuthorAvatar(comment.author),
+
const SizedBox(width: 8),
+
Expanded(
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author handle
+
Text(
+
'@${comment.author.handle}',
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.5,
+
),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
),
),
+
],
+
),
),
+
// Time ago
+
Text(
+
DateTimeUtils.formatTimeAgo(
+
comment.createdAt,
+
currentTime: currentTime,
+
),
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.5),
+
fontSize: 12,
+
),
),
+
],
+
),
+
const SizedBox(height: 8),
+
+
// Comment content
+
if (comment.content.isNotEmpty) ...[
+
_buildCommentContent(comment),
+
const SizedBox(height: 8),
],
+
// Action buttons (just vote for now)
+
_buildActionButtons(context),
],
+
),
),
+
],
+
),
),
),
);
+77 -14
lib/widgets/comment_thread.dart
···
/// - Limits nesting depth to prevent excessive indentation
/// - Shows "Load more replies" button when hasMore is true
/// - Supports tap-to-reply via [onCommentTap] callback
///
/// The [maxDepth] parameter controls how deeply nested comments can be
/// before they're rendered at the same level to prevent UI overflow.
class CommentThread extends StatelessWidget {
const CommentThread({
required this.thread,
···
this.currentTime,
this.onLoadMoreReplies,
this.onCommentTap,
super.key,
});
···
/// Callback when a comment is tapped (for reply functionality)
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
// Calculate effective depth (flatten after maxDepth)
final effectiveDepth = depth > maxDepth ? maxDepth : depth;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
-
// Render the comment with tap handler
CommentCard(
comment: thread.comment,
depth: effectiveDepth,
currentTime: currentTime,
onTap: onCommentTap != null ? () => onCommentTap!(thread) : null,
),
-
// Render replies recursively
-
if (thread.replies != null && thread.replies!.isNotEmpty)
-
...thread.replies!.map(
-
(reply) => CommentThread(
-
thread: reply,
-
depth: depth + 1,
-
maxDepth: maxDepth,
-
currentTime: currentTime,
-
onLoadMoreReplies: onLoadMoreReplies,
-
onCommentTap: onCommentTap,
-
),
),
-
// Show "Load more replies" button if there are more
-
if (thread.hasMore) _buildLoadMoreButton(context),
],
);
}
···
/// - Limits nesting depth to prevent excessive indentation
/// - Shows "Load more replies" button when hasMore is true
/// - Supports tap-to-reply via [onCommentTap] callback
+
/// - Supports long-press to collapse threads via [onCollapseToggle] callback
///
/// The [maxDepth] parameter controls how deeply nested comments can be
/// before they're rendered at the same level to prevent UI overflow.
+
///
+
/// When a comment is collapsed (via [collapsedComments]), its replies are
+
/// hidden with a smooth animation and a badge shows the hidden count.
class CommentThread extends StatelessWidget {
const CommentThread({
required this.thread,
···
this.currentTime,
this.onLoadMoreReplies,
this.onCommentTap,
+
this.collapsedComments = const {},
+
this.onCollapseToggle,
super.key,
});
···
/// Callback when a comment is tapped (for reply functionality)
final void Function(ThreadViewComment)? onCommentTap;
+
/// Set of collapsed comment URIs
+
final Set<String> collapsedComments;
+
+
/// Callback when a comment collapse state is toggled
+
final void Function(String uri)? onCollapseToggle;
+
+
/// Count all descendants recursively
+
static int countDescendants(ThreadViewComment thread) {
+
if (thread.replies == null || thread.replies!.isEmpty) {
+
return 0;
+
}
+
var count = thread.replies!.length;
+
for (final reply in thread.replies!) {
+
count += countDescendants(reply);
+
}
+
return count;
+
}
+
@override
Widget build(BuildContext context) {
// Calculate effective depth (flatten after maxDepth)
final effectiveDepth = depth > maxDepth ? maxDepth : depth;
+
// Check if this comment is collapsed
+
final isCollapsed = collapsedComments.contains(thread.comment.uri);
+
final collapsedCount = isCollapsed ? countDescendants(thread) : 0;
+
+
// Check if there are replies to render
+
final hasReplies = thread.replies != null && thread.replies!.isNotEmpty;
+
+
// Only build replies widget when NOT collapsed (optimization)
+
// When collapsed, AnimatedSwitcher shows SizedBox.shrink() so children
+
// are never mounted - no need to build them at all
+
final repliesWidget = hasReplies && !isCollapsed
+
? Column(
+
key: const ValueKey('replies'),
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: thread.replies!.map((reply) {
+
return CommentThread(
+
thread: reply,
+
depth: depth + 1,
+
maxDepth: maxDepth,
+
currentTime: currentTime,
+
onLoadMoreReplies: onLoadMoreReplies,
+
onCommentTap: onCommentTap,
+
collapsedComments: collapsedComments,
+
onCollapseToggle: onCollapseToggle,
+
);
+
}).toList(),
+
)
+
: null;
+
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+
// Render the comment with tap and long-press handlers
CommentCard(
comment: thread.comment,
depth: effectiveDepth,
currentTime: currentTime,
onTap: onCommentTap != null ? () => onCommentTap!(thread) : null,
+
onLongPress: onCollapseToggle != null
+
? () => onCollapseToggle!(thread.comment.uri)
+
: null,
+
isCollapsed: isCollapsed,
+
collapsedCount: collapsedCount,
),
+
// Render replies with animation
+
if (hasReplies)
+
AnimatedSwitcher(
+
duration: const Duration(milliseconds: 200),
+
switchInCurve: Curves.easeInOutCubicEmphasized,
+
switchOutCurve: Curves.easeInOutCubicEmphasized,
+
transitionBuilder: (Widget child, Animation<double> animation) {
+
return SizeTransition(
+
sizeFactor: animation,
+
axisAlignment: -1,
+
child: child,
+
);
+
},
+
child: isCollapsed
+
? const SizedBox.shrink(key: ValueKey('collapsed'))
+
: repliesWidget,
),
+
// Show "Load more replies" button if there are more (and not collapsed)
+
if (thread.hasMore && !isCollapsed) _buildLoadMoreButton(context),
],
);
}