diff --git a/_tmp_create_comment_block.txt b/_tmp_create_comment_block.txt new file mode 100644 index 0000000..4e8f435 --- /dev/null +++ b/_tmp_create_comment_block.txt @@ -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}) +} + diff --git a/_tmp_patch_post_handler.sh b/_tmp_patch_post_handler.sh new file mode 100644 index 0000000..1926000 --- /dev/null +++ b/_tmp_patch_post_handler.sh @@ -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 diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 7a1326c..81b0fc4 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -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, - AuthorID: userID, - Body: req.Body, - Status: "active", + parentUUID, err := uuid.Parse(postID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid post ID"}) + return } - if err := h.postRepo.CreateComment(c.Request.Context(), comment); err != nil { + tags := utils.ExtractHashtags(req.Body) + tone := "neutral" + cis := 0.8 + + post := &models.Post{ + AuthorID: userID, + Body: req.Body, + Status: "active", + ToneLabel: &tone, + CISScore: &cis, + BodyFormat: "plain", + Tags: tags, + IsBeacon: false, + IsActiveBeacon:false, + AllowChain: true, + Visibility: "public", + ChainParentID: &parentUUID, + } + + if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()}) 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 { diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index 04ba76c..83521db 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -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, ) diff --git a/sojorn_app/lib/screens/post/post_detail_screen_old.dart b/sojorn_app/lib/screens/post/post_detail_screen_old.dart deleted file mode 100644 index e134126..0000000 --- a/sojorn_app/lib/screens/post/post_detail_screen_old.dart +++ /dev/null @@ -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 createState() => _PostDetailScreenState(); -} - -class _PostDetailScreenState extends ConsumerState { - _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> _buildReplyLookup() { - final repliesByParent = >{}; - 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> 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, - ), - ); - } -} diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index c627673..19fe036 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -44,11 +44,21 @@ class _ThreadedConversationScreenState extends ConsumerState post.id == widget.rootPostId); + if (!hasRoot) { + rootPost ??= await api.getPostById(widget.rootPostId); + } + if (mounted) { setState(() { - _posts = posts; - _threadTree = ThreadNode.buildTree(posts); + if (rootPost != null && !hasRoot) { + _posts = [rootPost, ...posts]; + } else { + _posts = posts; + } + _threadTree = _posts.isEmpty ? null : ThreadNode.buildTree(_posts); _isLoading = false; }); } @@ -80,39 +90,28 @@ class _ThreadedConversationScreenState extends ConsumerState 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 editPost({ diff --git a/sojorn_app/lib/services/feed_navigation_service.dart b/sojorn_app/lib/services/feed_navigation_service.dart index f20ae81..7866e5e 100644 --- a/sojorn_app/lib/services/feed_navigation_service.dart +++ b/sojorn_app/lib/services/feed_navigation_service.dart @@ -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'), - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart b/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart index 06e191b..6ba5cad 100644 --- a/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart +++ b/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart @@ -93,9 +93,7 @@ class _GlassmorphicQuipsSheetState extends ConsumerState 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 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(); diff --git a/sojorn_app/lib/widgets/kinetic_thread_widget.dart b/sojorn_app/lib/widgets/kinetic_thread_widget.dart index 8e19845..8906c59 100644 --- a/sojorn_app/lib/widgets/kinetic_thread_widget.dart +++ b/sojorn_app/lib/widgets/kinetic_thread_widget.dart @@ -1,26 +1,34 @@ + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:timeago/timeago.dart' as timeago; -import 'package:flutter_animate/flutter_animate.dart'; import '../models/post.dart'; import '../models/thread_node.dart'; import '../providers/api_provider.dart'; import '../theme/app_theme.dart'; import '../widgets/media/signed_media_image.dart'; -import '../widgets/post/post_actions.dart'; /// Kinetic Spatial Engine widget for layer-based thread navigation class KineticThreadWidget extends ConsumerStatefulWidget { final ThreadNode rootNode; final Function(ThreadNode)? onLayerChanged; final Function()? onReplyPosted; + final VoidCallback? onRefreshRequested; + final bool isLoading; const KineticThreadWidget({ super.key, required this.rootNode, this.onLayerChanged, this.onReplyPosted, + this.onRefreshRequested, + this.isLoading = false, }); @override @@ -29,174 +37,275 @@ class KineticThreadWidget extends ConsumerStatefulWidget { class _KineticThreadWidgetState extends ConsumerState with TickerProviderStateMixin { - - // Layer navigation state ThreadNode? _currentFocusNode; List _layerStack = []; - final PageController _pageController = PageController(); - - // Animation controllers - late AnimationController _layerTransitionController; - late AnimationController _lineageBarController; + int _currentLayerIndex = 0; + + final FocusNode _focusNode = FocusNode(); + final FocusNode _replyFocusNode = FocusNode(); + late AnimationController _impactController; - - // Animations - late Animation _slideAnimation; - late Animation _scaleAnimation; - late Animation _fadeAnimation; - - // Reply state + late AnimationController _replyRevealController; + late AnimationController _leapController; + + late Animation _replyRevealAnimation; + late Animation _leapSlideAnimation; + + late final PageController _layerPageController; + bool _showInlineReply = false; final TextEditingController _replyController = TextEditingController(); bool _isPostingReply = false; - - // Rail collapsing state + final Set _collapsedRails = {}; - + String? _expandedSatelliteId; + String? _hoveredSatelliteId; + bool _useVerticalImpact = false; + double _scrubIntensity = 0.0; + @override void initState() { super.initState(); _currentFocusNode = widget.rootNode; _layerStack = [widget.rootNode]; - + _layerPageController = PageController(initialPage: 0); + _initializeAnimations(); + _replyController.addListener(_onReplyChanged); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); } - void _initializeAnimations() { - // Layer transition animations - _layerTransitionController = AnimationController( - duration: const Duration(milliseconds: 400), + _impactController = AnimationController( + duration: const Duration(milliseconds: 520), vsync: this, ); - - _slideAnimation = Tween( - begin: const Offset(1.0, 0.0), + + _replyRevealController = AnimationController( + duration: const Duration(milliseconds: 240), + vsync: this, + ); + + _leapController = AnimationController( + duration: const Duration(milliseconds: 360), + vsync: this, + ); + + _replyRevealAnimation = CurvedAnimation( + parent: _replyRevealController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + _leapSlideAnimation = Tween( + begin: const Offset(0, 0.25), end: Offset.zero, ).animate(CurvedAnimation( - parent: _layerTransitionController, - curve: Curves.easeOutCubic, - )); - - _scaleAnimation = Tween( - begin: 0.8, - end: 1.0, - ).animate(CurvedAnimation( - parent: _layerTransitionController, + parent: _leapController, curve: Curves.easeOutBack, )); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _layerTransitionController, - curve: Curves.easeInOut, - )); - - // Lineage bar animation - _lineageBarController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - // Impact animation for new replies - _impactController = AnimationController( - duration: const Duration(milliseconds: 600), - vsync: this, - ); } - + @override void dispose() { - _layerTransitionController.dispose(); - _lineageBarController.dispose(); _impactController.dispose(); - _pageController.dispose(); + _replyRevealController.dispose(); + _leapController.dispose(); + _replyController.removeListener(_onReplyChanged); _replyController.dispose(); + _focusNode.dispose(); + _replyFocusNode.dispose(); + _layerPageController.dispose(); super.dispose(); } - + + bool get _supportsHover { + if (kIsWeb) return true; + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + return true; + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return false; + } + } + + void _onReplyChanged() { + if (mounted) { + setState(() {}); + } + } + + void _triggerLayerTransition() { + HapticFeedback.heavyImpact(); + } void _drillDownToLayer(ThreadNode targetNode) { + if (_currentFocusNode?.post.id == targetNode.post.id) return; + setState(() { _currentFocusNode = targetNode; _layerStack.add(targetNode); + _currentLayerIndex = _layerStack.length - 1; _showInlineReply = false; + _expandedSatelliteId = null; + _replyRevealController.reverse(); + _useVerticalImpact = false; }); - - _layerTransitionController.forward().then((_) { - _layerTransitionController.reset(); - }); - - _lineageBarController.forward(); + + _triggerLayerTransition(); widget.onLayerChanged?.call(targetNode); + + _layerPageController.animateToPage( + _currentLayerIndex, + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutCubic, + ); } - - void _scrubToLayer(int layerIndex) { + + void _scrubToLayer(int layerIndex, {bool animate = true}) { if (layerIndex < 0 || layerIndex >= _layerStack.length) return; - + setState(() { _currentFocusNode = _layerStack[layerIndex]; _layerStack = _layerStack.sublist(0, layerIndex + 1); + _currentLayerIndex = layerIndex; _showInlineReply = false; + _expandedSatelliteId = null; + _replyRevealController.reverse(); }); - - _layerTransitionController.forward().then((_) { - _layerTransitionController.reset(); - }); - + + _triggerLayerTransition(); widget.onLayerChanged?.call(_currentFocusNode!); + + if (animate) { + _layerPageController.animateToPage( + _currentLayerIndex, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } else { + _layerPageController.jumpToPage(_currentLayerIndex); + } } - + void _toggleRailCollapse(ThreadNode node) { setState(() { if (_collapsedRails.contains(node.post.id)) { _collapsedRails.remove(node.post.id); } else { _collapsedRails.add(node.post.id); - - // Shatter animation _impactController.forward().then((_) { _impactController.reset(); }); } }); + + HapticFeedback.heavyImpact(); } - + bool _isRailCollapsed(ThreadNode node) { return _collapsedRails.contains(node.post.id); } - + + void _warpToNextSibling() { + final current = _currentFocusNode; + final parent = current?.parent; + if (current == null || parent == null) return; + + final currentIndex = parent.children.indexWhere((child) => child.post.id == current.post.id); + if (currentIndex == -1) return; + + final nextIndex = currentIndex + 1; + if (nextIndex >= parent.children.length) return; + + final nextSibling = parent.children[nextIndex]; + + setState(() { + _currentFocusNode = nextSibling; + _layerStack[_layerStack.length - 1] = nextSibling; + _currentLayerIndex = _layerStack.length - 1; + _expandedSatelliteId = null; + _useVerticalImpact = true; + }); + + _leapController.forward(from: 0); + _impactController.forward().then((_) => _impactController.reset()); + _triggerLayerTransition(); + widget.onLayerChanged?.call(nextSibling); + + Future.delayed(const Duration(milliseconds: 420), () { + if (mounted) { + setState(() => _useVerticalImpact = false); + } + }); + } + + @override + void didUpdateWidget(covariant KineticThreadWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.rootNode != widget.rootNode) { + _currentFocusNode = widget.rootNode; + _layerStack = [widget.rootNode]; + _currentLayerIndex = 0; + _expandedSatelliteId = null; + _layerPageController.jumpToPage(0); + } + } + + void _toggleInlineReply() { + setState(() { + _showInlineReply = !_showInlineReply; + if (_showInlineReply) { + _replyRevealController.forward(); + _replyController.clear(); + _replyFocusNode.requestFocus(); + } else { + _replyRevealController.reverse(); + _replyFocusNode.unfocus(); + } + }); + } Future _submitInlineReply() async { if (_replyController.text.trim().isEmpty || _currentFocusNode == null) return; - + setState(() => _isPostingReply = true); - + try { final api = ref.read(apiServiceProvider); - await api.publishComment( - postId: _currentFocusNode!.post.id, + final replyPost = await api.publishPost( body: _replyController.text.trim(), + chainParentId: _currentFocusNode!.post.id, + allowChain: true, ); - + + _insertReplyPost(replyPost); + _replyController.clear(); setState(() { _showInlineReply = false; _isPostingReply = false; }); - - // Impact animation + + _replyRevealController.reverse(); _impactController.forward().then((_) { _impactController.reset(); }); - + widget.onReplyPosted?.call(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text('Reply posted!'), backgroundColor: Colors.green, - duration: const Duration(seconds: 2), + duration: Duration(seconds: 2), ), ); } @@ -212,492 +321,892 @@ class _KineticThreadWidgetState extends ConsumerState setState(() => _isPostingReply = false); } } - + + void _insertReplyPost(Post replyPost) { + final focusNode = _currentFocusNode; + if (focusNode == null) return; + + final replyNode = ThreadNode( + post: replyPost, + children: [], + depth: focusNode.depth + 1, + parent: focusNode, + ); + + setState(() { + focusNode.children.add(replyNode); + focusNode.children.sort((a, b) => a.post.createdAt.compareTo(b.post.createdAt)); + }); + + _impactController.forward().then((_) { + _impactController.reset(); + }); + } + @override Widget build(BuildContext context) { - return Column( - children: [ - // Kinetic Trace Bar (Lineage Scrubber) - _buildKineticTraceBar(), - - // Main content area with layer navigation - Expanded( - child: _buildLayerContent(), - ), - - // Inline reply composer - if (_showInlineReply && _currentFocusNode != null) - _buildInlineReplyComposer(), - ], - ); - } - - Widget _buildKineticTraceBar() { - return AnimatedBuilder( - animation: _lineageBarController, - builder: (context, child) { - return Container( - height: 60, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.2), - width: 1, - ), + return Focus( + focusNode: _focusNode, + child: Stack( + children: [ + PageView.builder( + controller: _layerPageController, + itemCount: _layerStack.length, + onPageChanged: (index) { + if (index != _currentLayerIndex) { + _scrubToLayer(index, animate: false); + } + }, + itemBuilder: (context, index) { + return _buildLayerPage(_layerStack[index], index); + }, ), - child: Row( - children: [ - // Back button - if (_layerStack.length > 1) - GestureDetector( - onTap: () => _scrubToLayer(_layerStack.length - 2), + Positioned( + right: 20, + bottom: 24, + child: _buildLeapButton(), + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _scrubIntensity, + duration: const Duration(milliseconds: 160), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: Container( - margin: const EdgeInsets.only(left: 12), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.arrow_back, - size: 20, - color: AppTheme.brightNavy, - ), + color: AppTheme.scaffoldBg.withValues(alpha: 0.05), ), ), - - // Lineage avatars - Expanded( - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _layerStack.length, - itemBuilder: (context, index) { - final node = _layerStack[index]; - final isCurrentLayer = index == _layerStack.length - 1; - - return GestureDetector( - onTap: () => _scrubToLayer(index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isCurrentLayer - ? AppTheme.brightNavy - : AppTheme.navyBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - border: isCurrentLayer - ? null - : Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Mini avatar - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isCurrentLayer - ? Colors.white - : AppTheme.brightNavy.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - 'L${node.depth}', - style: GoogleFonts.inter( - color: isCurrentLayer - ? AppTheme.brightNavy - : AppTheme.brightNavy, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: 6), - // Author name - Text( - node.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: isCurrentLayer - ? Colors.white - : AppTheme.navyBlue, - fontSize: 12, - fontWeight: isCurrentLayer - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ], - ), - ), - ); - }, - ), ), + ), + ), + ], + ), + ); + } + + Widget _buildLayerPage(ThreadNode focusNode, int index) { + final hasChildren = focusNode.hasChildren; + + return CustomScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _KineticScrubberHeader( + layerStack: _layerStack, + currentIndex: _currentLayerIndex, + totalCount: _layerStack.isNotEmpty ? _layerStack.first.totalCount : null, + onRefreshRequested: widget.onRefreshRequested, + onScrubStart: _handleScrubStart, + onScrubEnd: _handleScrubEnd, + onScrubIndex: (value) => _scrubToLayer(value, animate: true), + isLoading: widget.isLoading, + ), + ), + SliverToBoxAdapter( + child: Column( + children: [ + if (focusNode.parent != null) _buildPreviousChainJump(focusNode.parent!), + _buildFocusPostAnimated(focusNode), ], ), - ); - }, - ); - } - - Widget _buildLayerContent() { - if (_currentFocusNode == null) { - return const Center(child: Text('No content')); - } - - return PageView.builder( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), // Disable manual swiping - itemCount: 1, - itemBuilder: (context, index) { - return AnimatedBuilder( - animation: _layerTransitionController, - builder: (context, child) { - return SlideTransition( - position: _slideAnimation, - child: FadeTransition( - opacity: _fadeAnimation, - child: ScaleTransition( - scale: _scaleAnimation, - child: _buildFocusLayer(_currentFocusNode!), - ), - ), - ); - }, - ); - }, - ); - } - - Widget _buildFocusLayer(ThreadNode focusNode) { - return Column( - children: [ - // Focus post (blooms to full width) - _buildFocusPost(focusNode), - - // Children list (high-velocity scroll) - if (focusNode.hasChildren) - Expanded( - child: _buildChildrenList(focusNode.children), + ), + if (hasChildren) + _buildSatelliteListSliver(focusNode.children) + else + SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyDiscoveryState(focusNode), ), ], ); } - - Widget _buildFocusPost(ThreadNode node) { - return Hero( - tag: 'post_${node.post.id}', + void _handleScrubStart() { + if (!mounted) return; + setState(() => _scrubIntensity = 1.0); + } + + void _handleScrubEnd() { + if (!mounted) return; + setState(() => _scrubIntensity = 0.0); + } + + Widget _buildLeapButton() { + return GestureDetector( + onTap: _warpToNextSibling, child: Container( - margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.2), - width: 2, - ), + color: AppTheme.brightNavy, + borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 4), + color: AppTheme.brightNavy.withValues(alpha: 0.4), + blurRadius: 18, + offset: const Offset(0, 8), ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - // Author and metadata - Padding( - padding: const EdgeInsets.all(16), + const Icon(Icons.keyboard_double_arrow_down, color: Colors.white, size: 18), + const SizedBox(width: 8), + Text( + 'Leap', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ], + ), + ), + ).animate().fadeIn(duration: 240.ms).scale(begin: const Offset(0.9, 0.9)); + } + + Widget _buildFocusPostAnimated(ThreadNode node) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 360), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final slideTween = _useVerticalImpact + ? Tween(begin: const Offset(0, 0.22), end: Offset.zero) + : Tween(begin: const Offset(0.15, 0), end: Offset.zero); + return SlideTransition( + position: slideTween.animate(animation), + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: KeyedSubtree( + key: ValueKey('sun_${node.post.id}'), + child: SlideTransition( + position: _leapSlideAnimation, + child: _buildFocusPost(node), + ), + ), + ); + } + + Widget _buildPreviousChainJump(ThreadNode parentNode) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: GestureDetector( + onTap: () { + if (_layerStack.length > 1) { + _scrubToLayer(_layerStack.length - 2); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.12), + ), + boxShadow: [ + BoxShadow( + color: AppTheme.navyBlue.withValues(alpha: 0.06), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), child: Row( children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - 'L${node.depth}', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: 12), + _buildMiniAvatar(parentNode), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - node.post.author?.displayName ?? 'Anonymous', + 'Previous chain', style: GoogleFonts.inter( - color: AppTheme.textPrimary, - fontSize: 16, + color: AppTheme.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 4), + Text( + parentNode.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: AppTheme.navyBlue.withValues(alpha: 0.85), + fontSize: 12, fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 2), Text( - timeago.format(node.post.createdAt), + parentNode.post.body, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: GoogleFonts.inter( - color: AppTheme.textSecondary, + color: AppTheme.navyText.withValues(alpha: 0.7), fontSize: 12, + height: 1.4, ), ), ], ), ), + const SizedBox(width: 8), + Icon( + Icons.arrow_upward, + size: 18, + color: AppTheme.brightNavy, + ), ], ), ), - - // Post content - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ), + ), + ).animate().fadeIn(duration: 220.ms).slideY(begin: -0.1, end: 0); + } + Widget _buildMiniAvatar(ThreadNode node) { + final avatarUrl = node.post.author?.avatarUrl; + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: avatarUrl == null + ? Center( child: Text( - node.post.body, + _initialForName(node.post.author?.displayName), style: GoogleFonts.inter( - fontSize: 16, - color: AppTheme.navyText, - height: 1.5, + color: AppTheme.brightNavy, + fontSize: 12, + fontWeight: FontWeight.w700, ), ), - ), - - // Media if present - if (node.post.imageUrl != null) ...[ - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SignedMediaImage( - url: node.post.imageUrl!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - ), - ), - ), - ], - - // Actions - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => setState(() => _showInlineReply = !_showInlineReply), - icon: Icon(Icons.reply, size: 18), - label: Text('Reply'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.brightNavy, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(width: 12), - if (node.hasChildren) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.egyptianBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 16, - color: AppTheme.egyptianBlue, - ), - const SizedBox(width: 4), - Text( - '${node.totalDescendants}', - style: GoogleFonts.inter( - color: AppTheme.egyptianBlue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], + ) + : ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SignedMediaImage( + url: avatarUrl, + width: 32, + height: 32, + fit: BoxFit.cover, ), ), - ], + ); + } + + Widget _buildEmptyDiscoveryState(ThreadNode focusNode) { + final canChain = focusNode.post.allowChain; + return Center( + child: Text( + canChain ? 'Be the first to reply' : 'Replies are disabled', + style: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 14, ), ), ); } - - Widget _buildChildrenList(List children) { - // Filter out collapsed rails - final visibleChildren = children.where((child) => !_isRailCollapsed(child)).toList(); - - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: visibleChildren.length, - itemBuilder: (context, index) { - final child = visibleChildren[index]; - return _buildChildItem(child, index); - }, - ); - } - - Widget _buildChildItem(ThreadNode child, int index) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.only(bottom: 12), - transform: Matrix4.translationValues(0, 0, 0), - child: GestureDetector( - onTap: () => _drillDownToLayer(child), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - width: 1, - ), + + Widget _buildFocusPost(ThreadNode node) { + final isLoading = widget.isLoading; + return Hero( + tag: 'thread_post_${node.post.id}', + child: Container( + margin: const EdgeInsets.fromLTRB(16, 6, 16, 10), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppTheme.brightNavy, + width: 2.5, ), - child: Row( - children: [ - // Power Rail (collapsible thread indicator) - GestureDetector( - onLongPress: () => _toggleRailCollapse(child), - onDoubleTap: () => _toggleRailCollapse(child), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: 4, - height: 40, - decoration: BoxDecoration( - color: _isRailCollapsed(child) - ? Colors.red.withValues(alpha: 0.5) - : AppTheme.brightNavy.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), + boxShadow: [ + BoxShadow( + color: AppTheme.brightNavy.withValues(alpha: 0.18), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Stack( + children: [ + Opacity( + opacity: isLoading ? 0.6 : 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + _buildAuthorAvatar(node), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + Text( + timeago.format(node.post.createdAt), + style: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + if (node.hasChildren) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 16, + color: AppTheme.egyptianBlue, + ), + const SizedBox(width: 4), + Text( + '${node.totalDescendants}', + style: GoogleFonts.inter( + color: AppTheme.egyptianBlue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: isLoading + ? _buildSkeletonBlock(lines: 4) + : Text( + node.post.body, + style: GoogleFonts.inter( + fontSize: 20, + color: AppTheme.navyText, + height: 1.7, + fontWeight: FontWeight.w500, + ), + ), + ), + if (!isLoading && node.post.imageUrl != null) ...[ + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SignedMediaImage( + url: node.post.imageUrl!, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: node.post.allowChain ? _toggleInlineReply : null, + icon: const Icon(Icons.reply, size: 18), + label: Text(_showInlineReply ? 'Close' : 'Reply'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.brightNavy, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + _buildInlineReplyComposer(), + ], + ), + ), + if (isLoading) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: 0.6, + duration: const Duration(milliseconds: 300), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.0), + Colors.white.withValues(alpha: 0.35), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.2, 0.5, 0.8], + ), + ), + ), ), ), ), + ], + ), + ), + ).animate().fadeIn(duration: 180.ms).scale(begin: const Offset(0.98, 0.98), end: const Offset(1, 1)); + } + Widget _buildSkeletonBlock({int lines = 3}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(lines, (index) { + final widthFactor = 1 - (index * 0.12).clamp(0.0, 0.35); + return Container( + height: 12, + margin: const EdgeInsets.only(bottom: 10), + width: MediaQuery.of(context).size.width * widthFactor, + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + ); + }), + ); + } + + Widget _buildAuthorAvatar(ThreadNode node) { + final avatarUrl = node.post.author?.avatarUrl; + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: avatarUrl == null + ? Center( + child: Text( + _initialForName(node.post.author?.displayName), + style: GoogleFonts.inter( + color: AppTheme.brightNavy, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SignedMediaImage( + url: avatarUrl, + width: 36, + height: 36, + fit: BoxFit.cover, + ), + ), + ); + } + + String _initialForName(String? name) { + final trimmed = name?.trim() ?? ''; + if (trimmed.isEmpty) return 'S'; + return trimmed.characters.first.toUpperCase(); + } + + List _rankChildren(List children) { + final ranked = List.from(children); + ranked.sort((a, b) { + final engagementCompare = b.totalDescendants.compareTo(a.totalDescendants); + if (engagementCompare != 0) return engagementCompare; + return a.post.createdAt.compareTo(b.post.createdAt); + }); + return ranked; + } + + SliverPadding _buildSatelliteListSliver(List children) { + final rankedChildren = _rankChildren(children); + + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final child = rankedChildren[index]; + return _buildChildRailItem(child, index); + }, + childCount: rankedChildren.length, + ), + ), + ); + } + void _handleSatelliteTap(ThreadNode child) { + if (_expandedSatelliteId != child.post.id) { + setState(() => _expandedSatelliteId = child.post.id); + return; + } + _drillDownToLayer(child); + } + + Widget _buildChildRailItem(ThreadNode child, int index) { + final isCollapsed = _isRailCollapsed(child); + final engagementScore = child.totalDescendants; + final isExpanded = _expandedSatelliteId == child.post.id; + final showPeek = _supportsHover && child.hasChildren && _hoveredSatelliteId == child.post.id; + + final card = AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutBack, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isExpanded + ? AppTheme.brightNavy.withValues(alpha: 0.6) + : AppTheme.navyBlue.withValues(alpha: 0.12), + width: isExpanded ? 1.6 : 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppTheme.navyBlue.withValues(alpha: isExpanded ? 0.14 : 0.06), + blurRadius: isExpanded ? 20 : 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (engagementScore > 0) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getEngagementColor(engagementScore).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.trending_up, + size: 12, + color: _getEngagementColor(engagementScore), + ), + const SizedBox(width: 4), + Text( + '$engagementScore ${engagementScore == 1 ? 'reply' : 'replies'}', + style: GoogleFonts.inter( + color: _getEngagementColor(engagementScore), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Text( + child.post.body, + style: GoogleFonts.inter( + color: AppTheme.navyText, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w500, + ), + maxLines: isExpanded ? 8 : 3, + overflow: TextOverflow.ellipsis, + ), + if (isExpanded && child.post.imageUrl != null) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SignedMediaImage( + url: child.post.imageUrl!, + width: double.infinity, + height: 160, + fit: BoxFit.cover, + ), + ), + ], + if (isExpanded && child.hasChildren) ...[ + const SizedBox(height: 12), + _buildInlineReplyPreview(child), + ], + const SizedBox(height: 14), + Row( + children: [ + _buildRail(child), const SizedBox(width: 12), - - // Child content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - 'L${child.depth}', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - child.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: AppTheme.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (child.hasChildren) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.egyptianBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${child.totalDescendants}', - style: GoogleFonts.inter( - color: AppTheme.egyptianBlue, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 4), Text( - child.post.body, + child.post.author?.displayName ?? 'Anonymous', style: GoogleFonts.inter( - color: AppTheme.navyText, - fontSize: 14, - height: 1.4, + color: AppTheme.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, ), - maxLines: 2, overflow: TextOverflow.ellipsis, ), + Text( + timeago.format(child.post.createdAt), + style: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 10, + ), + ), ], ), ), - - // Drill down indicator - Icon( - Icons.arrow_forward_ios, - size: 16, - color: AppTheme.textSecondary, + AnimatedScale( + duration: const Duration(milliseconds: 220), + scale: isExpanded ? 1.05 : 1, + child: Icon( + isExpanded ? Icons.north : Icons.expand_more, + color: AppTheme.brightNavy, + size: 18, + ), ), ], ), - ), + ], + ), + ); + + final cardBody = GestureDetector( + onTap: () => _handleSatelliteTap(child), + child: card, + ); + + final cardStack = Stack( + clipBehavior: Clip.none, + children: [ + cardBody, + if (showPeek) _buildPeekFlyout(child), + ], + ); + + final animatedCard = AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutBack, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isCollapsed ? 0.0 : 1.0, + child: isCollapsed ? const SizedBox.shrink() : cardStack, + ), + ); + + final content = Hero( + tag: 'thread_post_${child.post.id}', + child: Material( + color: Colors.transparent, + child: animatedCard, + ), + ); + + return MouseRegion( + onEnter: _supportsHover ? (_) => setState(() => _hoveredSatelliteId = child.post.id) : null, + onExit: _supportsHover ? (_) => setState(() => _hoveredSatelliteId = null) : null, + child: Container( + margin: const EdgeInsets.only(bottom: 14), + child: content, + ) + .animate(delay: (40 * index).ms) + .fadeIn(duration: 200.ms) + .slideX(begin: 0.08, end: 0, curve: Curves.easeOutCubic), + ); + } + Widget _buildInlineReplyPreview(ThreadNode child) { + final previewReplies = child.children.take(2).toList(); + if (previewReplies.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.12)), + ), + child: Column( + children: previewReplies + .map( + (reply) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: AppTheme.brightNavy, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + reply.post.body, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppTheme.navyText.withValues(alpha: 0.7), + height: 1.4, + ), + ), + ), + ], + ), + ), + ) + .toList(), ), ); } - - Widget _buildInlineReplyComposer() { - return AnimatedBuilder( - animation: _impactController, - builder: (context, child) { - return Transform.scale( - scale: 1.0 + (_impactController.value * 0.05), + + Widget _buildPeekFlyout(ThreadNode child) { + final previewReplies = child.children.take(3).toList(); + if (previewReplies.isEmpty) return const SizedBox.shrink(); + + return Positioned( + top: 20, + right: -220, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( - margin: const EdgeInsets.all(16), + width: 200, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.brightNavy.withValues(alpha: 0.2), + ), + boxShadow: [ + BoxShadow( + color: AppTheme.navyBlue.withValues(alpha: 0.12), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Peek replies', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.brightNavy, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 8), + ...previewReplies.map((reply) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + reply.post.body, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: AppTheme.navyText.withValues(alpha: 0.8), + height: 1.4, + ), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ).animate().fadeIn(duration: 180.ms).slideX(begin: 0.05, end: 0); + } + + Color _getEngagementColor(int engagementScore) { + if (engagementScore >= 10) return Colors.red; + if (engagementScore >= 5) return Colors.orange; + if (engagementScore >= 2) return AppTheme.egyptianBlue; + return AppTheme.brightNavy; + } + + Widget _buildRail(ThreadNode child) { + final isCollapsed = _isRailCollapsed(child); + return GestureDetector( + onTap: () => _toggleRailCollapse(child), + child: Container( + width: 10, + height: 48, + decoration: BoxDecoration( + color: isCollapsed + ? Colors.red.withValues(alpha: 0.55) + : AppTheme.brightNavy.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(6), + ), + ) + .animate(target: isCollapsed ? 1 : 0) + .shake(duration: 240.ms, hz: 16) + .fade(begin: 1.0, end: 0.7), + ); + } + Widget _buildInlineReplyComposer() { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return SizeTransition( + sizeFactor: _replyRevealAnimation, + child: FadeTransition( + opacity: _replyRevealAnimation, + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomInset > 0 ? bottomInset + 16 : 16), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.navyBlue.withValues(alpha: 0.05), @@ -714,32 +1223,40 @@ class _KineticThreadWidgetState extends ConsumerState children: [ Icon(Icons.reply, size: 20, color: AppTheme.navyBlue), const SizedBox(width: 8), - Text( - 'Replying to ${_currentFocusNode!.post.author?.displayName ?? 'Anonymous'}', - style: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.navyBlue, - fontWeight: FontWeight.w600, + Expanded( + child: Text( + 'Replying to ${_currentFocusNode?.post.author?.displayName ?? 'Anonymous'}', + style: GoogleFonts.inter( + fontSize: 14, + color: AppTheme.navyBlue, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), GestureDetector( - onTap: () => setState(() => _showInlineReply = false), + onTap: _toggleInlineReply, child: Icon(Icons.close, size: 20, color: AppTheme.navyBlue), ), ], ), const SizedBox(height: 12), TextField( + focusNode: _replyFocusNode, controller: _replyController, maxLines: 3, minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) { + if (_replyController.text.trim().isNotEmpty && !_isPostingReply) { + _submitInlineReply(); + } + }, style: GoogleFonts.inter( fontSize: 14, color: AppTheme.navyText, ), decoration: InputDecoration( - hintText: 'Write your reply...', + hintText: 'Write your reply... ', hintStyle: GoogleFonts.inter( fontSize: 14, color: AppTheme.textSecondary, @@ -777,7 +1294,7 @@ class _KineticThreadWidgetState extends ConsumerState ), ), child: _isPostingReply - ? SizedBox( + ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( @@ -798,8 +1315,188 @@ class _KineticThreadWidgetState extends ConsumerState ], ), ), - ); - }, + ), + ), ); } } + +class _KineticScrubberHeader extends SliverPersistentHeaderDelegate { + final List layerStack; + final int currentIndex; + final int? totalCount; + final VoidCallback? onRefreshRequested; + final VoidCallback onScrubStart; + final VoidCallback onScrubEnd; + final ValueChanged onScrubIndex; + final bool isLoading; + + _KineticScrubberHeader({ + required this.layerStack, + required this.currentIndex, + required this.totalCount, + required this.onRefreshRequested, + required this.onScrubStart, + required this.onScrubEnd, + required this.onScrubIndex, + required this.isLoading, + }); + + @override + double get maxExtent => 132; + + @override + double get minExtent => 110; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final showCount = totalCount != null && totalCount! > 0; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + decoration: BoxDecoration( + color: AppTheme.scaffoldBg.withValues(alpha: overlapsContent ? 0.96 : 0.9), + border: Border( + bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06)), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thread', + style: GoogleFonts.literata( + fontWeight: FontWeight.w600, + color: AppTheme.navyBlue, + fontSize: 18, + ), + ), + if (showCount) + Text( + '${totalCount!} ${totalCount == 1 ? 'comment' : 'comments'}', + style: GoogleFonts.inter( + color: AppTheme.textDisabled, + fontSize: 12, + ), + ), + ], + ), + const Spacer(), + IconButton( + onPressed: isLoading ? null : onRefreshRequested, + icon: Icon(Icons.refresh, color: AppTheme.navyBlue), + tooltip: 'Refresh thread', + ), + ], + ), + const SizedBox(height: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final count = layerStack.isEmpty ? 1 : layerStack.length; + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => onScrubStart(), + onHorizontalDragEnd: (_) => onScrubEnd(), + onHorizontalDragCancel: onScrubEnd, + onHorizontalDragUpdate: (details) { + final index = _scrubIndexFromOffset(details.localPosition.dx, width, count); + onScrubIndex(index); + }, + onTapDown: (details) { + final index = _scrubIndexFromOffset(details.localPosition.dx, width, count); + onScrubIndex(index); + }, + child: Row( + children: List.generate(count, (index) { + final node = layerStack[index]; + final isActive = index == currentIndex; + return Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + height: 44, + decoration: BoxDecoration( + color: isActive + ? AppTheme.brightNavy.withValues(alpha: 0.12) + : AppTheme.navyBlue.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isActive + ? AppTheme.brightNavy.withValues(alpha: 0.5) + : AppTheme.navyBlue.withValues(alpha: 0.08), + ), + ), + margin: EdgeInsets.only(right: index == count - 1 ? 0 : 8), + child: Center( + child: _buildScrubberAvatar(node, isActive), + ), + ), + ); + }), + ), + ); + }, + ), + ), + ], + ), + ); + } + + int _scrubIndexFromOffset(double dx, double width, int count) { + if (count <= 1 || width <= 0) return 0; + final slot = width / count; + return (dx / slot).floor().clamp(0, count - 1); + } + + Widget _buildScrubberAvatar(ThreadNode node, bool isActive) { + final avatarUrl = node.post.author?.avatarUrl; + return AnimatedScale( + duration: const Duration(milliseconds: 180), + scale: isActive ? 1.08 : 0.98, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.brightNavy.withValues(alpha: 0.2), + ), + child: avatarUrl == null + ? Center( + child: Text( + (node.post.author?.displayName ?? 'S').characters.first.toUpperCase(), + style: GoogleFonts.inter( + color: AppTheme.brightNavy, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ) + : ClipOval( + child: SignedMediaImage( + url: avatarUrl, + width: 32, + height: 32, + fit: BoxFit.cover, + ), + ), + ), + ); + } + + @override + bool shouldRebuild(covariant _KineticScrubberHeader oldDelegate) { + return oldDelegate.layerStack != layerStack || + oldDelegate.currentIndex != currentIndex || + oldDelegate.totalCount != totalCount || + oldDelegate.isLoading != isLoading; + } +} diff --git a/sojorn_app/lib/widgets/threaded_comment_widget.dart b/sojorn_app/lib/widgets/threaded_comment_widget.dart deleted file mode 100644 index 30dc528..0000000 --- a/sojorn_app/lib/widgets/threaded_comment_widget.dart +++ /dev/null @@ -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 createState() => _ThreadedCommentWidgetState(); -} - -class _ThreadedCommentWidgetState extends State - 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 _expandAnimation; - late Animation _hoverAnimation; - late Animation _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(), - ), - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/threaded_comment_widget_old.dart b/sojorn_app/lib/widgets/threaded_comment_widget_old.dart deleted file mode 100644 index 5c31b83..0000000 --- a/sojorn_app/lib/widgets/threaded_comment_widget_old.dart +++ /dev/null @@ -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 createState() => _ThreadedCommentWidgetState(); -} - -class _ThreadedCommentWidgetState extends State - with TickerProviderStateMixin { - bool _isExpanded = true; - bool _isHovering = false; - late AnimationController _expandController; - late AnimationController _hoverController; - late Animation _expandAnimation; - late Animation _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(), - ); - } -}