chains debugging

This commit is contained in:
Patrick Britton 2026-01-30 20:56:57 -06:00
parent ca59f3286a
commit 94c5c2095e
12 changed files with 1450 additions and 1935 deletions

View file

@ -0,0 +1,56 @@
func (h *PostHandler) CreateComment(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
userID, _ := uuid.Parse(userIDStr.(string))
postID := c.Param("id")
var req struct {
Body string `json:"body" binding:"required,max=500"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
parentUUID, err := uuid.Parse(postID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid post ID"})
return
}
tags := utils.ExtractHashtags(req.Body)
tone := "neutral"
cis := 0.8
post := &models.Post{
AuthorID: userID,
Body: req.Body,
Status: "active",
ToneLabel: &tone,
CISScore: &cis,
BodyFormat: "plain",
Tags: tags,
IsBeacon: false,
IsActiveBeacon: false,
AllowChain: true,
Visibility: "public",
ChainParentID: &parentUUID,
}
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
return
}
comment := &models.Comment{
ID: post.ID,
PostID: postID,
AuthorID: post.AuthorID,
Body: post.Body,
Status: "active",
CreatedAt: post.CreatedAt,
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
python - <<'PY'
from pathlib import Path
path = Path("/opt/sojorn/go-backend/internal/handlers/post_handler.go")
text = path.read_text()
if "chain_parent_id" not in text:
text = text.replace("\t\tDurationMS *int `json:\"duration_ms\"`\n\t\tIsBeacon", "\t\tDurationMS *int `json:\"duration_ms\"`\n\t\tAllowChain *bool `json:\"allow_chain\"`\n\t\tChainParentID *string `json:\"chain_parent_id\"`\n\t\tIsBeacon")
if "allowChain := !req.IsBeacon" not in text:
marker = "post := &models.Post{\n"
if marker in text:
text = text.replace(marker, "allowChain := !req.IsBeacon\n\tif req.AllowChain != nil {\n\t\tallowChain = *req.AllowChain\n\t}\n\n\t" + marker, 1)
text = text.replace("\t\tAllowChain: !req.IsBeacon,\n", "\t\tAllowChain: allowChain,\n")
marker = "\tif req.CategoryID != nil {\n\t\tcatID, _ := uuid.Parse(*req.CategoryID)\n\t\tpost.CategoryID = &catID\n\t}\n"
if marker in text and "post.ChainParentID" not in text:
text = text.replace(marker, marker + "\n\tif req.ChainParentID != nil && *req.ChainParentID != \"\" {\n\t\tparentID, err := uuid.Parse(*req.ChainParentID)\n\t\tif err == nil {\n\t\t\tpost.ChainParentID = &parentID\n\t\t}\n\t}\n", 1)
path.write_text(text)
PY

View file

@ -10,6 +10,7 @@ import (
"github.com/patbritton/sojorn-backend/internal/repository" "github.com/patbritton/sojorn-backend/internal/repository"
"github.com/patbritton/sojorn-backend/internal/services" "github.com/patbritton/sojorn-backend/internal/services"
"github.com/patbritton/sojorn-backend/pkg/utils" "github.com/patbritton/sojorn-backend/pkg/utils"
"github.com/rs/zerolog/log"
) )
type PostHandler struct { type PostHandler struct {
@ -44,29 +45,56 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
return return
} }
comment := &models.Comment{ parentUUID, err := uuid.Parse(postID)
PostID: postID, if err != nil {
AuthorID: userID, c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid post ID"})
Body: req.Body, return
Status: "active",
} }
if err := h.postRepo.CreateComment(c.Request.Context(), comment); err != nil { tags := utils.ExtractHashtags(req.Body)
tone := "neutral"
cis := 0.8
post := &models.Post{
AuthorID: userID,
Body: req.Body,
Status: "active",
ToneLabel: &tone,
CISScore: &cis,
BodyFormat: "plain",
Tags: tags,
IsBeacon: false,
IsActiveBeacon:false,
AllowChain: true,
Visibility: "public",
ChainParentID: &parentUUID,
}
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
return return
} }
comment := &models.Comment{
ID: post.ID,
PostID: postID,
AuthorID: post.AuthorID,
Body: post.Body,
Status: "active",
CreatedAt: post.CreatedAt,
}
// Get post details for notification // Get post details for notification
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string)) rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
if err == nil && post.AuthorID.String() != userIDStr.(string) { if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
// Get actor details // Get actor details
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string)) actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
if err == nil && h.notificationService != nil { if err == nil && h.notificationService != nil {
// Determine post type for proper deep linking // Determine post type for proper deep linking
postType := "standard" postType := "standard"
if post.IsBeacon { if rootPost.IsBeacon {
postType = "beacon" postType = "beacon"
} else if post.VideoURL != nil && *post.VideoURL != "" { } else if rootPost.VideoURL != nil && *rootPost.VideoURL != "" {
postType = "quip" postType = "quip"
} }
@ -78,7 +106,7 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
} }
h.notificationService.CreateNotification( h.notificationService.CreateNotification(
c.Request.Context(), c.Request.Context(),
post.AuthorID.String(), rootPost.AuthorID.String(),
userIDStr.(string), userIDStr.(string),
"comment", "comment",
&postID, &postID,
@ -116,6 +144,8 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
VideoURL *string `json:"video_url"` VideoURL *string `json:"video_url"`
Thumbnail *string `json:"thumbnail_url"` Thumbnail *string `json:"thumbnail_url"`
DurationMS *int `json:"duration_ms"` DurationMS *int `json:"duration_ms"`
AllowChain *bool `json:"allow_chain"`
ChainParentID *string `json:"chain_parent_id"`
IsBeacon bool `json:"is_beacon"` IsBeacon bool `json:"is_beacon"`
BeaconType *string `json:"beacon_type"` BeaconType *string `json:"beacon_type"`
BeaconLat *float64 `json:"beacon_lat"` BeaconLat *float64 `json:"beacon_lat"`
@ -154,6 +184,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
duration = *req.DurationMS duration = *req.DurationMS
} }
allowChain := !req.IsBeacon
if req.AllowChain != nil {
allowChain = *req.AllowChain
}
if req.ChainParentID != nil && *req.ChainParentID != "" {
log.Info().
Str("chain_parent_id", *req.ChainParentID).
Bool("allow_chain", allowChain).
Msg("CreatePost with chain parent")
} else {
log.Info().
Bool("allow_chain", allowChain).
Msg("CreatePost without chain parent")
}
post := &models.Post{ post := &models.Post{
AuthorID: userID, AuthorID: userID,
Body: req.Body, Body: req.Body,
@ -170,7 +216,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
BeaconType: req.BeaconType, BeaconType: req.BeaconType,
Confidence: 0.5, // Initial confidence Confidence: 0.5, // Initial confidence
IsActiveBeacon: req.IsBeacon, IsActiveBeacon: req.IsBeacon,
AllowChain: !req.IsBeacon, AllowChain: allowChain,
Visibility: "public", Visibility: "public",
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
Lat: req.BeaconLat, Lat: req.BeaconLat,
@ -182,6 +228,13 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
post.CategoryID = &catID post.CategoryID = &catID
} }
if req.ChainParentID != nil && *req.ChainParentID != "" {
parentID, err := uuid.Parse(*req.ChainParentID)
if err == nil {
post.ChainParentID = &parentID
}
}
// Create post // Create post
err = h.postRepo.CreatePost(c.Request.Context(), post) err = h.postRepo.CreatePost(c.Request.Context(), post)
if err != nil { if err != nil {

View file

@ -588,12 +588,42 @@ func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]mod
LEFT JOIN public.post_metrics m ON p.id = m.post_id LEFT JOIN public.post_metrics m ON p.id = m.post_id
JOIN object_chain oc ON p.chain_parent_id = oc.id JOIN object_chain oc ON p.chain_parent_id = oc.id
WHERE p.deleted_at IS NULL WHERE p.deleted_at IS NULL
),
comments_chain AS (
SELECT
c.id,
c.author_id,
NULL::uuid as category_id,
c.body,
'' as image_url,
'' as video_url,
'' as thumbnail_url,
0 as duration_ms,
ARRAY[]::text[] as tags,
c.created_at,
c.post_id as chain_parent_id,
pr.handle as author_handle,
pr.display_name as author_display_name,
COALESCE(pr.avatar_url, '') as author_avatar_url,
0 as like_count,
0 as comment_count,
2 as level
FROM public.comments c
JOIN public.profiles pr ON c.author_id = pr.id
WHERE c.deleted_at IS NULL
AND c.post_id IN (SELECT id FROM object_chain)
) )
SELECT SELECT
id, author_id, category_id, body, image_url, video_url, thumbnail_url, duration_ms, tags, created_at, chain_parent_id, -- Fixed: Added chain_parent_id id, author_id, category_id, body, image_url, video_url, thumbnail_url, duration_ms, tags, created_at, chain_parent_id, level,
author_handle, author_display_name, author_avatar_url, author_handle, author_display_name, author_avatar_url,
like_count, comment_count, FALSE as is_liked like_count, comment_count, FALSE as is_liked
FROM object_chain FROM object_chain
UNION ALL
SELECT
id, author_id, category_id, body, image_url, video_url, thumbnail_url, duration_ms, tags, created_at, chain_parent_id, level,
author_handle, author_display_name, author_avatar_url,
like_count, comment_count, FALSE as is_liked
FROM comments_chain
ORDER BY level ASC, created_at ASC; ORDER BY level ASC, created_at ASC;
` `
rows, err := r.pool.Query(ctx, query, rootID) rows, err := r.pool.Query(ctx, query, rootID)
@ -605,9 +635,10 @@ func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]mod
var posts []models.Post var posts []models.Post
for rows.Next() { for rows.Next() {
var p models.Post var p models.Post
var level int
// Fixed: Added Scan for &p.ChainParentID // Fixed: Added Scan for &p.ChainParentID
err := rows.Scan( err := rows.Scan(
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt, &p.ChainParentID, &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt, &p.ChainParentID, &level,
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL, &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
&p.LikeCount, &p.CommentCount, &p.IsLiked, &p.LikeCount, &p.CommentCount, &p.IsLiked,
) )

View file

@ -1,179 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/post.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/threaded_comment_widget.dart';
import '../compose/compose_screen.dart';
class PostDetailScreen extends ConsumerStatefulWidget {
final Post post;
const PostDetailScreen({
super.key,
required this.post,
});
@override
ConsumerState<PostDetailScreen> createState() => _PostDetailScreenState();
}
class _PostDetailScreenState extends ConsumerState<PostDetailScreen> {
_openPostDetail(_chainParentPost!);
return;
}
try {
final apiService = ref.read(apiServiceProvider);
final post = await apiService.getPostById(parentId);
if (!mounted) return;
setState(() {
_chainParentPost = post;
_chainParent = PostPreview.fromPost(post);
});
_openPostDetail(post);
} catch (_) {
// Ignore
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
body: ThreadedCommentWidget(
rootPost: widget.post,
onReply: (parentPost) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ComposeScreen(
chainParentPost: parentPost,
),
),
);
},
onPostTap: (post) {
// Could navigate to post detail if needed
},
),
);
}
Widget _buildChainContainer() {
if (!widget.post.allowChain) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingLg,
vertical: AppTheme.spacingMd
),
child: _buildChainContent(),
);
}
Widget _buildChainContent() {
if (_isLoadingChain) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Center(child: CircularProgressIndicator()),
);
}
if (_chainError != null) {
return Center(
child: TextButton(
onPressed: _loadChainPosts,
child: const Text('Load responses'),
),
);
}
final repliesByParent = _buildReplyLookup();
if (repliesByParent.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Text(
'No responses yet',
style: AppTheme.textTheme.labelSmall?.copyWith(
color: AppTheme.egyptianBlue,
fontStyle: FontStyle.italic,
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// "Next in Thread" Header
Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingSm),
child: Text(
'In this thread',
style: AppTheme.textTheme.labelMedium?.copyWith(
color: AppTheme.navyText.withOpacity(0.8),
fontWeight: FontWeight.w700,
),
),
),
_buildThreadedResponses(widget.post.id, 0, repliesByParent),
],
);
}
Map<String, List<Post>> _buildReplyLookup() {
final repliesByParent = <String, List<Post>>{};
for (final post in _chainPosts) {
if (post.id == widget.post.id) continue;
final parentId = post.chainParentId ?? widget.post.id;
repliesByParent.putIfAbsent(parentId, () => []).add(post);
}
for (final list in repliesByParent.values) {
list.sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
return repliesByParent;
}
Widget _buildThreadedResponses(String parentId, int depth, Map<String, List<Post>> lookup) {
final children = lookup[parentId];
if (children == null || children.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children.map((post) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildThreadedPostRow(post, depth),
_buildThreadedResponses(post.id, depth + 1, lookup),
],
);
}).toList(),
);
}
Widget _buildThreadedPostRow(Post post, int depth) {
return Container(
margin: const EdgeInsets.only(bottom: AppTheme.spacingSm),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: AppTheme.egyptianBlue.withOpacity(0.5),
width: 2,
),
),
),
padding: EdgeInsets.only(
left: AppTheme.spacingMd + depth * 12,
),
child: UnifiedPostTile(
post: post,
onTap: () => _openPostDetail(post),
onChain: () => _openChainComposerFor(post),
showDivider: false,
),
);
}
}

View file

@ -44,11 +44,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
// Load the conversation thread from backend // Load the conversation thread from backend
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
final posts = await api.getPostChain(widget.rootPostId); final posts = await api.getPostChain(widget.rootPostId);
Post? rootPost = widget.rootPost;
final hasRoot = posts.any((post) => post.id == widget.rootPostId);
if (!hasRoot) {
rootPost ??= await api.getPostById(widget.rootPostId);
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_posts = posts; if (rootPost != null && !hasRoot) {
_threadTree = ThreadNode.buildTree(posts); _posts = [rootPost, ...posts];
} else {
_posts = posts;
}
_threadTree = _posts.isEmpty ? null : ThreadNode.buildTree(_posts);
_isLoading = false; _isLoading = false;
}); });
} }
@ -80,39 +90,28 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue), icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
), ),
title: Column( title: const SizedBox.shrink(),
crossAxisAlignment: CrossAxisAlignment.start, actions: const [],
children: [
Text(
'Thread',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 18,
),
),
if (_threadTree != null)
Text(
'${_threadTree!.totalCount} ${_threadTree!.totalCount == 1 ? "comment" : "comments"}',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
actions: [
IconButton(
onPressed: _loadThread,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
tooltip: 'Refresh thread',
),
],
); );
} }
Widget _buildBody() { Widget _buildBody() {
if (_isLoading) { if (_isLoading) {
if (widget.rootPost != null) {
final placeholderRoot = ThreadNode(
post: widget.rootPost!,
children: [],
depth: 0,
);
return KineticThreadWidget(
rootNode: placeholderRoot,
isLoading: true,
onLayerChanged: _handleLayerChange,
onReplyPosted: _loadThread,
onRefreshRequested: _loadThread,
);
}
return const Center( return const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -145,7 +144,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
); );
} }
if (_threadTree == null) { if (_posts.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'Thread not found', 'Thread not found',
@ -160,19 +159,22 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadThread, onRefresh: _loadThread,
color: AppTheme.brightNavy, color: AppTheme.brightNavy,
child: _threadTree == null ? Center( child: _threadTree == null
child: Text( ? Center(
'Thread not found', child: Text(
style: GoogleFonts.inter( 'Thread not found',
color: AppTheme.textSecondary, style: GoogleFonts.inter(
fontSize: 16, color: AppTheme.textSecondary,
), fontSize: 16,
), ),
) : KineticThreadWidget( ),
rootNode: _threadTree!, )
onLayerChanged: _handleLayerChange, : KineticThreadWidget(
onReplyPosted: _loadThread, rootNode: _threadTree!,
), onLayerChanged: _handleLayerChange,
onReplyPosted: _loadThread,
onRefreshRequested: _loadThread,
),
); );
} }

View file

@ -510,12 +510,24 @@ class ApiService {
required String postId, required String postId,
required String body, required String body,
}) async { }) async {
final data = await _callGoApi( // Backward-compatible: create a chained post so threads render immediately.
'/posts/$postId/comments', final post = await publishPost(
method: 'POST', body: body,
body: {'body': body}, chainParentId: postId,
allowChain: true,
);
return Comment(
id: post.id,
postId: postId,
authorId: post.authorId,
body: post.body,
status: CommentStatus.active,
createdAt: post.createdAt,
updatedAt: post.editedAt,
author: post.author,
voteCount: null,
); );
return Comment.fromJson(data['comment']);
} }
Future<void> editPost({ Future<void> editPost({

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../screens/post/threaded_conversation_screen.dart';
/// Navigation service for opening different feeds based on post type /// Navigation service for opening different feeds based on post type
class FeedNavigationService { class FeedNavigationService {
@ -55,25 +56,3 @@ class QuipsFeedScreen extends StatelessWidget {
); );
} }
} }
/// Placeholder for ThreadedConversationScreen (already implemented)
class ThreadedConversationScreen extends StatelessWidget {
final String rootPostId;
const ThreadedConversationScreen({
super.key,
required this.rootPostId,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Thread'),
),
body: Center(
child: Text('Threaded Conversation: $rootPostId'),
),
);
}
}

View file

@ -93,9 +93,7 @@ class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
if (mounted) { if (mounted) {
setState(() { setState(() {
_quips = quips; _quips = quips;
if (quips.isNotEmpty) { _threadTree = quips.isEmpty ? null : ThreadNode.buildTree(quips);
_threadTree = ThreadNode.buildTree(quips);
}
_isLoading = false; _isLoading = false;
}); });
_glassController.forward(); _glassController.forward();
@ -117,9 +115,10 @@ class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
try { try {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
await api.publishComment( await api.publishPost(
postId: widget.postId,
body: _quipController.text.trim(), body: _quipController.text.trim(),
chainParentId: widget.postId,
allowChain: true,
); );
_quipController.clear(); _quipController.clear();

File diff suppressed because it is too large Load diff

View file

@ -1,570 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../models/post.dart';
import '../models/thread_node.dart';
import '../theme/app_theme.dart';
import '../widgets/media/signed_media_image.dart';
import '../widgets/post/post_actions.dart';
/// Recursive widget for rendering threaded conversations (Reddit-style)
class ThreadedCommentWidget extends StatefulWidget {
final ThreadNode node;
final VoidCallback? onReply;
final VoidCallback? onLike;
final Function(ThreadNode)? onNavigateToParent;
final Function(ThreadNode)? onNavigateToChild;
final bool isRootPost;
const ThreadedCommentWidget({
super.key,
required this.node,
this.onReply,
this.onLike,
this.onNavigateToParent,
this.onNavigateToChild,
this.isRootPost = false,
});
@override
State<ThreadedCommentWidget> createState() => _ThreadedCommentWidgetState();
}
class _ThreadedCommentWidgetState extends State<ThreadedCommentWidget>
with TickerProviderStateMixin {
bool _isExpanded = true;
bool _isHovering = false;
bool _showReplyBox = false;
final _replyController = TextEditingController();
late AnimationController _expandController;
late AnimationController _hoverController;
late AnimationController _replyBoxController;
late Animation<double> _expandAnimation;
late Animation<double> _hoverAnimation;
late Animation<double> _replyBoxAnimation;
@override
void initState() {
super.initState();
// Expand/collapse animation
_expandController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _expandController,
curve: Curves.easeInOut,
);
_expandController.forward();
// Hover animation
_hoverController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_hoverAnimation = CurvedAnimation(
parent: _hoverController,
curve: Curves.easeInOut,
);
// Reply box animation
_replyBoxController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_replyBoxAnimation = CurvedAnimation(
parent: _replyBoxController,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_expandController.dispose();
_hoverController.dispose();
_replyBoxController.dispose();
_replyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The post content with indentation
_buildPostWithIndentation(),
// Thread line connector
if (widget.node.hasChildren && _isExpanded)
_buildThreadLine(),
// Recursive children with animation
if (widget.node.hasChildren)
_buildChildren(),
// Inline reply box
_buildInlineReplyBox(),
],
),
);
}
Widget _buildPostWithIndentation() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: EdgeInsets.only(
left: widget.isRootPost ? 0 : (widget.node.depth * 16.0 + 8.0),
right: 16,
top: widget.isRootPost ? 0 : 8,
bottom: 8,
),
decoration: BoxDecoration(
border: widget.isRootPost ? null : Border(
left: BorderSide(
color: AppTheme.navyBlue.withValues(alpha: _isHovering ? 0.6 : 0.2),
width: _isHovering ? 3 : 2,
),
),
borderRadius: BorderRadius.circular(8),
color: _isHovering ? AppTheme.navyBlue.withValues(alpha: 0.05) : null,
),
child: MouseRegion(
onEnter: (_) {
setState(() => _isHovering = true);
_hoverController.forward();
},
onExit: (_) {
setState(() => _isHovering = false);
_hoverController.reverse();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chain navigation header
if (!widget.isRootPost) _buildChainNavigation(),
// Post content
_buildPostContent(),
],
),
),
);
}
Widget _buildChainNavigation() {
final totalReplies = widget.node.totalDescendants;
final hasParent = widget.node.parent != null;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
// Navigate up chain
if (hasParent)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onTap: () {
if (widget.onNavigateToParent != null && widget.node.parent != null) {
widget.onNavigateToParent!(widget.node.parent!);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_upward, size: 14, color: AppTheme.navyBlue),
const SizedBox(width: 4),
Text('Parent', style: TextStyle(fontSize: 12, color: AppTheme.navyBlue)),
],
),
),
),
),
const Spacer(),
// Chain navigation buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Reply count with expand/collapse
if (widget.node.hasChildren)
GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_expandController.forward();
} else {
_expandController.reverse();
}
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 14,
color: AppTheme.egyptianBlue,
key: ValueKey(_isExpanded),
),
),
const SizedBox(width: 4),
Text(
'$totalReplies ${totalReplies == 1 ? "reply" : "replies"}',
style: TextStyle(fontSize: 12, color: AppTheme.egyptianBlue),
),
],
),
),
),
// Quick navigation to first child
if (widget.node.hasChildren && widget.node.children.isNotEmpty)
const SizedBox(width: 8),
if (widget.node.hasChildren && widget.node.children.isNotEmpty)
GestureDetector(
onTap: () {
if (widget.onNavigateToChild != null) {
widget.onNavigateToChild!(widget.node.children.first);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.arrow_downward,
size: 12,
color: AppTheme.brightNavy,
),
),
),
],
),
],
),
);
}
Widget _buildPostContent() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author info and metadata
_buildAuthorRow(),
const SizedBox(height: 8),
// Post body
_buildPostBody(),
// Media if present
if (widget.node.post.imageUrl != null) ...[
const SizedBox(height: 8),
_buildPostImage(),
],
const SizedBox(height: 8),
// Actions bar
_buildActionsBar(),
],
),
);
}
Widget _buildPostBody() {
return Text(
widget.node.post.body,
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.navyText,
height: 1.4,
),
);
}
Widget _buildPostImage() {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SignedMediaImage(
url: widget.node.post.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
);
}
Widget _buildActionsBar() {
return PostActions(
post: widget.node.post,
onChain: _toggleReplyBox,
);
}
Widget _buildInlineReplyBox() {
return SizeTransition(
sizeFactor: _replyBoxAnimation,
child: FadeTransition(
opacity: _replyBoxAnimation,
child: Container(
margin: EdgeInsets.only(
left: widget.isRootPost ? 0 : (widget.node.depth * 16.0 + 8.0),
right: 16,
top: 8,
bottom: 16,
),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Reply header
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.reply, size: 16, color: AppTheme.navyBlue),
const SizedBox(width: 8),
Text(
'Replying to ${widget.node.post.author?.displayName ?? 'Anonymous'}',
style: GoogleFonts.inter(
fontSize: 12,
color: AppTheme.navyBlue,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
GestureDetector(
onTap: _toggleReplyBox,
child: Icon(Icons.close, size: 16, color: AppTheme.navyBlue),
),
],
),
),
// Text input
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TextField(
controller: _replyController,
maxLines: 3,
minLines: 1,
style: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.navyText,
),
decoration: InputDecoration(
hintText: 'Write your reply...',
hintStyle: GoogleFonts.inter(
fontSize: 14,
color: AppTheme.textSecondary,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
// Action buttons
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Image button (placeholder for now)
IconButton(
onPressed: () {
// TODO: Add image functionality
},
icon: Icon(Icons.image, size: 18, color: AppTheme.textSecondary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 16),
// Character count
Text(
'${_replyController.text.length}/500',
style: GoogleFonts.inter(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const Spacer(),
// Post button
ElevatedButton(
onPressed: _replyController.text.trim().isNotEmpty ? _submitReply : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: Text(
'Reply',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
),
),
);
}
void _toggleReplyBox() {
setState(() {
_showReplyBox = !_showReplyBox;
if (_showReplyBox) {
_replyBoxController.forward();
_replyController.clear();
FocusScope.of(context).requestFocus(FocusNode());
} else {
_replyBoxController.reverse();
}
});
}
void _submitReply() async {
if (_replyController.text.trim().isEmpty) return;
// TODO: Implement actual reply submission
// For now, just close the box and clear the text
setState(() {
_showReplyBox = false;
_replyBoxController.reverse();
_replyController.clear();
});
// Call the original onReply callback if provided
if (widget.onReply != null) {
widget.onReply!();
}
}
Widget _buildAuthorRow() {
return Row(
children: [
// Avatar
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
'L${widget.node.depth}',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.node.post.author?.displayName ?? 'Anonymous',
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Text(
timeago.format(widget.node.post.createdAt),
style: GoogleFonts.inter(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
],
);
}
Widget _buildThreadLine() {
return Container(
height: 20,
margin: EdgeInsets.only(
left: widget.isRootPost ? 0 : (widget.node.depth * 16.0 + 8.0 + 16),
),
child: Container(
width: 2,
color: AppTheme.navyBlue.withValues(alpha: 0.3),
),
);
}
Widget _buildChildren() {
return SizeTransition(
sizeFactor: _expandAnimation,
child: FadeTransition(
opacity: _expandAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.node.children.map((child) =>
ThreadedCommentWidget(
node: child,
onReply: widget.onReply,
onLike: widget.onLike,
onNavigateToParent: widget.onNavigateToParent,
onNavigateToChild: widget.onNavigateToChild,
),
).toList(),
),
),
);
}
}

View file

@ -1,583 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../models/post.dart';
import '../models/thread_node.dart';
import '../theme/app_theme.dart';
import '../widgets/media/signed_media_image.dart';
import '../widgets/post/post_actions.dart';
/// Recursive widget for rendering threaded conversations (Reddit-style)
class ThreadedCommentWidget extends StatefulWidget {
final ThreadNode node;
final VoidCallback? onReply;
final VoidCallback? onLike;
final bool isRootPost;
const ThreadedCommentWidget({
super.key,
required this.node,
this.onReply,
this.onLike,
this.isRootPost = false,
});
@override
State<ThreadedCommentWidget> createState() => _ThreadedCommentWidgetState();
}
class _ThreadedCommentWidgetState extends State<ThreadedCommentWidget>
with TickerProviderStateMixin {
bool _isExpanded = true;
bool _isHovering = false;
late AnimationController _expandController;
late AnimationController _hoverController;
late Animation<double> _expandAnimation;
late Animation<double> _hoverAnimation;
@override
void initState() {
super.initState();
// Expand/collapse animation
_expandController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _expandController,
curve: Curves.easeInOut,
);
_expandController.forward();
// Hover animation
_hoverController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_hoverAnimation = CurvedAnimation(
parent: _hoverController,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_expandController.dispose();
_hoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The post content with indentation
_buildPostWithIndentation(),
// Thread line connector
if (widget.node.hasChildren && _isExpanded)
_buildThreadLine(),
// Recursive children with animation
if (widget.node.hasChildren)
_buildChildren(),
],
),
);
}
Widget _buildPostWithIndentation() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: EdgeInsets.only(
left: widget.isRootPost ? 0 : (widget.node.depth * 16.0 + 8.0),
right: 16,
top: widget.isRootPost ? 0 : 8,
bottom: 8,
),
decoration: BoxDecoration(
border: widget.isRootPost ? null : Border(
left: BorderSide(
color: AppTheme.navyBlue.withValues(alpha: _isHovering ? 0.6 : 0.2),
width: _isHovering ? 3 : 2,
),
),
borderRadius: BorderRadius.circular(8),
color: _isHovering ? AppTheme.navyBlue.withValues(alpha: 0.05) : null,
),
child: MouseRegion(
onEnter: (_) {
setState(() => _isHovering = true);
_hoverController.forward();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chain navigation header
if (!widget.isRootPost) _buildChainNavigation(),
// Post content
_buildPostContent(),
],
),
),
);
}
Widget _buildChainNavigation() {
final totalReplies = widget.node.totalDescendants;
final hasParent = widget.node.parent != null;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
// Navigate up chain
if (hasParent)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onTap: () {
// TODO: Navigate to parent
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_upward, size: 14, color: AppTheme.navyBlue),
const SizedBox(width: 4),
Text('Parent', style: TextStyle(fontSize: 12, color: AppTheme.navyBlue)),
],
),
),
),
),
const Spacer(),
// Reply count with expand/collapse
if (widget.node.hasChildren)
GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_expandController.forward();
} else {
_expandController.reverse();
}
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 14,
color: AppTheme.egyptianBlue,
key: ValueKey(_isExpanded),
),
),
const SizedBox(width: 4),
Text(
'$totalReplies ${totalReplies == 1 ? "reply" : "replies"}',
style: TextStyle(fontSize: 12, color: AppTheme.egyptianBlue),
),
],
),
),
),
],
),
);
}
Widget _buildPostContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author info and metadata
_buildAuthorRow(),
const SizedBox(height: 8),
// Post body
_buildPostBody(),
// Media if present
if (widget.node.post.imageUrl != null) ...[
const SizedBox(height: 8),
_buildPostImage(),
],
const SizedBox(height: 8),
// Actions bar
_buildActionsBar(),
],
);
}
Widget _buildPostImage() {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SignedMediaImage(
url: widget.node.post.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
);
}
Widget _buildActionsBar() {
return Row(
children: [
// Like button
InkWell(
onTap: widget.onLike,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
widget.node.post.likeCount.toString(),
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
const SizedBox(width: 16),
// Reply button
InkWell(
onTap: widget.onReply,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.reply,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
'Reply',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
const Spacer(),
// Expand/Collapse button for threads
if (widget.node.hasChildren)
InkWell(
onTap: () => setState(() => _isExpanded = !_isExpanded),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
'${widget.node.totalCount - 1}',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
],
);
}
Widget _buildAuthorRow() {
return Row(
children: [
// Avatar
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: widget.node.post.author?.avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SignedMediaImage(
url: widget.node.post.author!.avatarUrl!,
width: 32,
height: 32,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
widget.node.post.author?.displayName?.isNotEmpty == true
? widget.node.post.author!.displayName![0].toUpperCase()
: widget.node.post.author?.handle?.isNotEmpty == true
? widget.node.post.author!.handle![0].toUpperCase()
: '?',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
const SizedBox(width: 8),
// Author name and handle
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.node.post.author?.displayName ??
widget.node.post.author?.handle ??
'Anonymous',
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 14,
),
),
if (widget.node.post.author?.handle != null &&
widget.node.post.author!.handle != widget.node.post.author?.displayName)
Text(
'@${widget.node.post.author!.handle}',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
// Timestamp
Text(
timeago.format(widget.node.post.createdAt),
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
// Depth indicator for replies
if (!widget.isRootPost && widget.node.depth > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'L${widget.node.depth}',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
],
);
}
Widget _buildPostContent() {
return Text(
widget.node.post.body,
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 14,
height: 1.4,
),
);
}
Widget _buildPostImage() {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SignedMediaImage(
url: widget.node.post.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
);
}
Widget _buildActionsBar() {
return Row(
children: [
// Like button
InkWell(
onTap: widget.onLike,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite_border,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
widget.node.post.likeCount.toString(),
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
const SizedBox(width: 16),
// Reply button
InkWell(
onTap: widget.onReply,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.reply,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
'Reply',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
const Spacer(),
// Expand/Collapse button for threads
if (widget.node.hasChildren)
InkWell(
onTap: () => setState(() => _isExpanded = !_isExpanded),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: AppTheme.textDisabled,
),
const SizedBox(width: 4),
Text(
'${widget.node.totalCount - 1}',
style: GoogleFonts.inter(
color: AppTheme.textDisabled,
fontSize: 12,
),
),
],
),
),
),
],
);
}
Widget _buildThreadLine() {
return Container(
margin: EdgeInsets.only(
left: widget.isRootPost ? 0 : (widget.node.depth * 16.0 + 8.0),
),
child: Container(
width: 2,
height: 8,
color: AppTheme.navyBlue.withValues(alpha: 0.2),
),
);
}
Widget _buildChildren() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.node.children.map((child) {
return ThreadedCommentWidget(
node: child,
onReply: widget.onReply,
onLike: widget.onLike,
isRootPost: false,
);
}).toList(),
);
}
}