feat: add comment UI widgets with colorful threading indicators

CommentCard:
- Display individual comments with author info and content
- Colorful threading indicators (6-color palette) showing nesting depth
- Optimistic voting via VoteProvider integration
- Right-aligned vote button with proper accessibility labels
- Custom painter for vertical threading lines (2px stroke, 6px spacing)
- Dynamic left padding based on nesting depth

CommentThread:
- Recursive rendering of nested comment replies
- Proper depth tracking and max depth limiting
- "Load more replies" button support

CommentsHeader:
- Comment count display with pluralization
- Sort dropdown (Hot/Top/New) with visual feedback
- Empty state handling

Visual features:
- Threading lines extend through full comment height
- Border dividers respect threading indicator spacing
- Subtle author name and timestamp styling (50% opacity)
- Consistent spacing (6px per level + 14px base padding)

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

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

+322
lib/widgets/comment_card.dart
···
+
import 'package:cached_network_image/cached_network_image.dart';
+
import 'package:flutter/foundation.dart';
+
import 'package:flutter/material.dart';
+
import 'package:flutter/services.dart';
+
import 'package:provider/provider.dart';
+
+
import '../constants/app_colors.dart';
+
import '../models/comment.dart';
+
import '../models/post.dart';
+
import '../providers/auth_provider.dart';
+
import '../providers/vote_provider.dart';
+
import '../utils/date_time_utils.dart';
+
import 'icons/animated_heart_icon.dart';
+
import 'sign_in_dialog.dart';
+
+
/// Comment card widget for displaying individual comments
+
///
+
/// Displays a comment with:
+
/// - Author information (avatar, handle, timestamp)
+
/// - Comment content (supports facets for links/mentions)
+
/// - Heart vote button with optimistic updates via VoteProvider
+
/// - Visual threading indicator based on nesting depth
+
///
+
/// 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,
+
super.key,
+
});
+
+
final CommentView comment;
+
final int depth;
+
final DateTime? currentTime;
+
+
@override
+
Widget build(BuildContext context) {
+
// All comments get at least 1 threading line (depth + 1)
+
final threadingLineCount = depth + 1;
+
// Calculate left padding: (6px per line) + 14px base padding
+
final leftPadding = (threadingLineCount * 6.0) + 14.0;
+
// Border should start after the threading lines (add 2px to clear the stroke width)
+
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
+
+
return 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),
+
],
+
),
+
),
+
],
+
),
+
);
+
}
+
+
/// Builds the author avatar widget
+
Widget _buildAuthorAvatar(AuthorView author) {
+
if (author.avatar != null && author.avatar!.isNotEmpty) {
+
// Show real author avatar
+
return ClipRRect(
+
borderRadius: BorderRadius.circular(12),
+
child: CachedNetworkImage(
+
imageUrl: author.avatar!,
+
width: 14,
+
height: 14,
+
fit: BoxFit.cover,
+
placeholder: (context, url) => _buildFallbackAvatar(author),
+
errorWidget: (context, url, error) => _buildFallbackAvatar(author),
+
),
+
);
+
}
+
+
// Fallback to letter placeholder
+
return _buildFallbackAvatar(author);
+
}
+
+
/// Builds a fallback avatar with the first letter of handle
+
Widget _buildFallbackAvatar(AuthorView author) {
+
final firstLetter = author.handle.isNotEmpty ? author.handle[0] : '?';
+
return Container(
+
width: 24,
+
height: 24,
+
decoration: BoxDecoration(
+
color: AppColors.primary,
+
borderRadius: BorderRadius.circular(12),
+
),
+
child: Center(
+
child: Text(
+
firstLetter.toUpperCase(),
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 12,
+
fontWeight: FontWeight.bold,
+
),
+
),
+
),
+
);
+
}
+
+
/// Builds the comment content with support for facets
+
Widget _buildCommentContent(CommentView comment) {
+
// TODO: Add facet support for links and mentions like PostCard does
+
// For now, just render plain text
+
return Text(
+
comment.content,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 14,
+
height: 1.4,
+
),
+
);
+
}
+
+
/// Builds the action buttons row (vote button)
+
Widget _buildActionButtons(BuildContext context) {
+
return Consumer<VoteProvider>(
+
builder: (context, voteProvider, child) {
+
// Get optimistic vote state from provider
+
final isLiked = voteProvider.isLiked(comment.uri);
+
final adjustedScore = voteProvider.getAdjustedScore(
+
comment.uri,
+
comment.stats.score,
+
);
+
+
return Row(
+
mainAxisAlignment: MainAxisAlignment.end,
+
children: [
+
// Heart vote button
+
Semantics(
+
button: true,
+
label: isLiked
+
? 'Unlike comment, $adjustedScore '
+
'${adjustedScore == 1 ? "like" : "likes"}'
+
: 'Like comment, $adjustedScore '
+
'${adjustedScore == 1 ? "like" : "likes"}',
+
child: InkWell(
+
onTap: () async {
+
// Check authentication
+
final authProvider = context.read<AuthProvider>();
+
if (!authProvider.isAuthenticated) {
+
// Show sign-in dialog
+
final shouldSignIn = await SignInDialog.show(
+
context,
+
message: 'You need to sign in to vote on comments.',
+
);
+
+
if ((shouldSignIn ?? false) && context.mounted) {
+
// TODO: Navigate to sign-in screen
+
if (kDebugMode) {
+
debugPrint('Navigate to sign-in screen');
+
}
+
}
+
return;
+
}
+
+
// Light haptic feedback
+
await HapticFeedback.lightImpact();
+
+
// Toggle vote with optimistic update via VoteProvider
+
try {
+
await voteProvider.toggleVote(
+
postUri: comment.uri,
+
postCid: comment.cid,
+
);
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('Failed to vote on comment: $e');
+
}
+
// TODO: Show error snackbar
+
}
+
},
+
child: Padding(
+
padding: const EdgeInsets.symmetric(
+
horizontal: 8,
+
vertical: 6,
+
),
+
child: Row(
+
mainAxisSize: MainAxisSize.min,
+
children: [
+
AnimatedHeartIcon(
+
isLiked: isLiked,
+
size: 16,
+
color: AppColors.textPrimary.withValues(alpha: 0.6),
+
likedColor: const Color(0xFFFF0033),
+
),
+
const SizedBox(width: 5),
+
Text(
+
DateTimeUtils.formatCount(adjustedScore),
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.6),
+
fontSize: 12,
+
),
+
),
+
],
+
),
+
),
+
),
+
),
+
],
+
);
+
},
+
);
+
}
+
}
+
+
/// Custom painter for drawing comment depth indicator lines
+
class _CommentDepthPainter extends CustomPainter {
+
final int depth;
+
+
_CommentDepthPainter({
+
required this.depth,
+
});
+
+
// Color palette for threading indicators (cycles through 6 colors)
+
static final List<Color> _threadingColors = [
+
const Color(0xFFFF6B6B), // Red
+
const Color(0xFF4ECDC4), // Teal
+
const Color(0xFFFFE66D), // Yellow
+
const Color(0xFF95E1D3), // Mint
+
const Color(0xFFC7CEEA), // Purple
+
const Color(0xFFFFAA5C), // Orange
+
];
+
+
@override
+
void paint(Canvas canvas, Size size) {
+
final paint = Paint()
+
..strokeWidth = 2.0
+
..style = PaintingStyle.stroke;
+
+
// Draw vertical line for each depth level with different colors
+
for (int i = 0; i < depth; i++) {
+
// Cycle through colors based on depth level
+
paint.color = _threadingColors[i % _threadingColors.length].withValues(alpha: 0.5);
+
+
final xPosition = (i + 1) * 6.0;
+
canvas.drawLine(
+
Offset(xPosition, 0),
+
Offset(xPosition, size.height),
+
paint,
+
);
+
}
+
}
+
+
@override
+
bool shouldRepaint(_CommentDepthPainter oldDelegate) {
+
return oldDelegate.depth != depth;
+
}
+
}
+113
lib/widgets/comment_thread.dart
···
+
import 'package:flutter/foundation.dart';
+
import 'package:flutter/material.dart';
+
+
import '../constants/app_colors.dart';
+
import '../models/comment.dart';
+
import 'comment_card.dart';
+
+
/// Comment thread widget for displaying comments and their nested replies
+
///
+
/// Recursively displays a ThreadViewComment and its replies:
+
/// - Renders the comment using CommentCard with optimistic voting
+
/// via VoteProvider
+
/// - Indents nested replies visually
+
/// - Limits nesting depth to prevent excessive indentation
+
/// - Shows "Load more replies" button when hasMore is true
+
///
+
/// 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.depth = 0,
+
this.maxDepth = 5,
+
this.currentTime,
+
this.onLoadMoreReplies,
+
super.key,
+
});
+
+
final ThreadViewComment thread;
+
final int depth;
+
final int maxDepth;
+
final DateTime? currentTime;
+
final VoidCallback? onLoadMoreReplies;
+
+
@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
+
CommentCard(
+
comment: thread.comment,
+
depth: effectiveDepth,
+
currentTime: currentTime,
+
),
+
+
// 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,
+
),
+
),
+
+
// Show "Load more replies" button if there are more
+
if (thread.hasMore) _buildLoadMoreButton(context),
+
],
+
);
+
}
+
+
/// Builds the "Load more replies" button
+
Widget _buildLoadMoreButton(BuildContext context) {
+
// Calculate left padding based on depth (align with replies)
+
final effectiveDepth = depth > maxDepth ? maxDepth : depth;
+
final leftPadding = 16.0 + ((effectiveDepth + 1) * 12.0);
+
+
return Container(
+
padding: EdgeInsets.fromLTRB(leftPadding, 8, 16, 8),
+
decoration: const BoxDecoration(
+
border: Border(bottom: BorderSide(color: AppColors.border)),
+
),
+
child: InkWell(
+
onTap: () {
+
if (onLoadMoreReplies != null) {
+
onLoadMoreReplies!();
+
} else {
+
if (kDebugMode) {
+
debugPrint('Load more replies tapped (no handler provided)');
+
}
+
}
+
},
+
child: Padding(
+
padding: const EdgeInsets.symmetric(vertical: 4),
+
child: Row(
+
children: [
+
Icon(
+
Icons.add_circle_outline,
+
size: 16,
+
color: AppColors.primary.withValues(alpha: 0.8),
+
),
+
const SizedBox(width: 6),
+
Text(
+
'Load more replies',
+
style: TextStyle(
+
color: AppColors.primary.withValues(alpha: 0.8),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
+
),
+
),
+
],
+
),
+
),
+
),
+
);
+
}
+
}
+113
lib/widgets/comments_header.dart
···
+
import 'package:flutter/material.dart';
+
+
import '../constants/app_colors.dart';
+
+
/// Comments section header with sort dropdown
+
///
+
/// Displays:
+
/// - Comment count with pluralization
+
/// - Sort dropdown (Hot/Top/New)
+
/// - Empty state when no comments
+
class CommentsHeader extends StatelessWidget {
+
const CommentsHeader({
+
required this.commentCount,
+
required this.currentSort,
+
required this.onSortChanged,
+
super.key,
+
});
+
+
final int commentCount;
+
final String currentSort;
+
final void Function(String) onSortChanged;
+
+
static const _sortOptions = ['hot', 'top', 'new'];
+
static const _sortLabels = ['Hot', 'Top', 'New'];
+
+
@override
+
Widget build(BuildContext context) {
+
// Show empty state if no comments
+
if (commentCount == 0) {
+
return Container(
+
padding: const EdgeInsets.symmetric(vertical: 32),
+
child: Column(
+
children: [
+
const Icon(
+
Icons.chat_bubble_outline,
+
size: 48,
+
color: AppColors.textSecondary,
+
),
+
const SizedBox(height: 16),
+
Text(
+
'No comments yet',
+
style: TextStyle(
+
fontSize: 16,
+
color: AppColors.textPrimary.withValues(alpha: 0.7),
+
),
+
),
+
const SizedBox(height: 4),
+
Text(
+
'Be the first to comment',
+
style: TextStyle(
+
fontSize: 14,
+
color: AppColors.textSecondary.withValues(alpha: 0.7),
+
),
+
),
+
],
+
),
+
);
+
}
+
+
// Show comment count and sort dropdown
+
return Container(
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+
child: Row(
+
children: [
+
// Comment count with dropdown
+
Expanded(
+
child: PopupMenuButton<String>(
+
initialValue: currentSort,
+
onSelected: onSortChanged,
+
offset: const Offset(0, 40),
+
color: AppColors.backgroundSecondary,
+
child: Row(
+
children: [
+
Text(
+
'$commentCount ${commentCount == 1 ? 'Comment' : 'Comments'}',
+
style: const TextStyle(
+
fontSize: 15,
+
color: AppColors.textSecondary,
+
fontWeight: FontWeight.w600,
+
),
+
),
+
const SizedBox(width: 6),
+
const Icon(
+
Icons.arrow_drop_down,
+
color: AppColors.textSecondary,
+
size: 20,
+
),
+
],
+
),
+
itemBuilder: (context) => [
+
for (var i = 0; i < _sortOptions.length; i++)
+
PopupMenuItem<String>(
+
value: _sortOptions[i],
+
child: Text(
+
_sortLabels[i],
+
style: TextStyle(
+
color: currentSort == _sortOptions[i]
+
? AppColors.primary
+
: AppColors.textPrimary,
+
fontWeight: currentSort == _sortOptions[i]
+
? FontWeight.w600
+
: FontWeight.normal,
+
),
+
),
+
),
+
],
+
),
+
),
+
],
+
),
+
);
+
}
+
}