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/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 {
|
||||||
|
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,
|
AuthorID: userID,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
Status: "active",
|
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()})
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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(() {
|
||||||
|
if (rootPost != null && !hasRoot) {
|
||||||
|
_posts = [rootPost, ...posts];
|
||||||
|
} else {
|
||||||
_posts = posts;
|
_posts = posts;
|
||||||
_threadTree = ThreadNode.buildTree(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,7 +159,8 @@ 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
|
||||||
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Thread not found',
|
'Thread not found',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
|
|
@ -168,10 +168,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) : KineticThreadWidget(
|
)
|
||||||
|
: KineticThreadWidget(
|
||||||
rootNode: _threadTree!,
|
rootNode: _threadTree!,
|
||||||
onLayerChanged: _handleLayerChange,
|
onLayerChanged: _handleLayerChange,
|
||||||
onReplyPosted: _loadThread,
|
onReplyPosted: _loadThread,
|
||||||
|
onRefreshRequested: _loadThread,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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