chains debugging
This commit is contained in:
parent
ca59f3286a
commit
94c5c2095e
56
_tmp_create_comment_block.txt
Normal file
56
_tmp_create_comment_block.txt
Normal 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})
|
||||
}
|
||||
|
||||
18
_tmp_patch_post_handler.sh
Normal file
18
_tmp_patch_post_handler.sh
Normal 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
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/patbritton/sojorn-backend/pkg/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PostHandler struct {
|
||||
|
|
@ -44,29 +45,56 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
comment := &models.Comment{
|
||||
PostID: postID,
|
||||
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.CreateComment(c.Request.Context(), comment); err != nil {
|
||||
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,
|
||||
}
|
||||
|
||||
// Get post details for notification
|
||||
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err == nil && post.AuthorID.String() != userIDStr.(string) {
|
||||
rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
|
||||
// Get actor details
|
||||
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
||||
if err == nil && h.notificationService != nil {
|
||||
// Determine post type for proper deep linking
|
||||
postType := "standard"
|
||||
if post.IsBeacon {
|
||||
if rootPost.IsBeacon {
|
||||
postType = "beacon"
|
||||
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
||||
} else if rootPost.VideoURL != nil && *rootPost.VideoURL != "" {
|
||||
postType = "quip"
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +106,7 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
|
|||
}
|
||||
h.notificationService.CreateNotification(
|
||||
c.Request.Context(),
|
||||
post.AuthorID.String(),
|
||||
rootPost.AuthorID.String(),
|
||||
userIDStr.(string),
|
||||
"comment",
|
||||
&postID,
|
||||
|
|
@ -116,6 +144,8 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
VideoURL *string `json:"video_url"`
|
||||
Thumbnail *string `json:"thumbnail_url"`
|
||||
DurationMS *int `json:"duration_ms"`
|
||||
AllowChain *bool `json:"allow_chain"`
|
||||
ChainParentID *string `json:"chain_parent_id"`
|
||||
IsBeacon bool `json:"is_beacon"`
|
||||
BeaconType *string `json:"beacon_type"`
|
||||
BeaconLat *float64 `json:"beacon_lat"`
|
||||
|
|
@ -154,6 +184,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
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{
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
|
|
@ -170,7 +216,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
BeaconType: req.BeaconType,
|
||||
Confidence: 0.5, // Initial confidence
|
||||
IsActiveBeacon: req.IsBeacon,
|
||||
AllowChain: !req.IsBeacon,
|
||||
AllowChain: allowChain,
|
||||
Visibility: "public",
|
||||
ExpiresAt: expiresAt,
|
||||
Lat: req.BeaconLat,
|
||||
|
|
@ -182,6 +228,13 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
post.CategoryID = &catID
|
||||
}
|
||||
|
||||
if req.ChainParentID != nil && *req.ChainParentID != "" {
|
||||
parentID, err := uuid.Parse(*req.ChainParentID)
|
||||
if err == nil {
|
||||
post.ChainParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
// Create post
|
||||
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
JOIN object_chain oc ON p.chain_parent_id = oc.id
|
||||
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
|
||||
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,
|
||||
like_count, comment_count, FALSE as is_liked
|
||||
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;
|
||||
`
|
||||
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
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
var level int
|
||||
// Fixed: Added Scan for &p.ChainParentID
|
||||
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.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,11 +44,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
// Load the conversation thread from backend
|
||||
final api = ref.read(apiServiceProvider);
|
||||
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) {
|
||||
setState(() {
|
||||
if (rootPost != null && !hasRoot) {
|
||||
_posts = [rootPost, ...posts];
|
||||
} else {
|
||||
_posts = posts;
|
||||
_threadTree = ThreadNode.buildTree(posts);
|
||||
}
|
||||
_threadTree = _posts.isEmpty ? null : ThreadNode.buildTree(_posts);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -80,39 +90,28 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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',
|
||||
),
|
||||
],
|
||||
title: const SizedBox.shrink(),
|
||||
actions: const [],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
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(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
@ -145,7 +144,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
);
|
||||
}
|
||||
|
||||
if (_threadTree == null) {
|
||||
if (_posts.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Thread not found',
|
||||
|
|
@ -160,7 +159,8 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
return RefreshIndicator(
|
||||
onRefresh: _loadThread,
|
||||
color: AppTheme.brightNavy,
|
||||
child: _threadTree == null ? Center(
|
||||
child: _threadTree == null
|
||||
? Center(
|
||||
child: Text(
|
||||
'Thread not found',
|
||||
style: GoogleFonts.inter(
|
||||
|
|
@ -168,10 +168,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
) : KineticThreadWidget(
|
||||
)
|
||||
: KineticThreadWidget(
|
||||
rootNode: _threadTree!,
|
||||
onLayerChanged: _handleLayerChange,
|
||||
onReplyPosted: _loadThread,
|
||||
onRefreshRequested: _loadThread,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -510,12 +510,24 @@ class ApiService {
|
|||
required String postId,
|
||||
required String body,
|
||||
}) async {
|
||||
final data = await _callGoApi(
|
||||
'/posts/$postId/comments',
|
||||
method: 'POST',
|
||||
body: {'body': body},
|
||||
// Backward-compatible: create a chained post so threads render immediately.
|
||||
final post = await publishPost(
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/post.dart';
|
||||
import '../screens/post/threaded_conversation_screen.dart';
|
||||
|
||||
/// Navigation service for opening different feeds based on post type
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,9 +93,7 @@ class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
|
|||
if (mounted) {
|
||||
setState(() {
|
||||
_quips = quips;
|
||||
if (quips.isNotEmpty) {
|
||||
_threadTree = ThreadNode.buildTree(quips);
|
||||
}
|
||||
_threadTree = quips.isEmpty ? null : ThreadNode.buildTree(quips);
|
||||
_isLoading = false;
|
||||
});
|
||||
_glassController.forward();
|
||||
|
|
@ -117,9 +115,10 @@ class _GlassmorphicQuipsSheetState extends ConsumerState<GlassmorphicQuipsSheet>
|
|||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.publishComment(
|
||||
postId: widget.postId,
|
||||
await api.publishPost(
|
||||
body: _quipController.text.trim(),
|
||||
chainParentId: widget.postId,
|
||||
allowChain: true,
|
||||
);
|
||||
|
||||
_quipController.clear();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue