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 '../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/reactions/reactions_display.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 ConsumerState createState() => _KineticThreadWidgetState(); } class _KineticThreadWidgetState extends ConsumerState with TickerProviderStateMixin { ThreadNode? _currentFocusNode; List _layerStack = []; int _currentLayerIndex = 0; final FocusNode _focusNode = FocusNode(); final FocusNode _replyFocusNode = FocusNode(); late AnimationController _impactController; 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; 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() { _impactController = AnimationController( duration: const Duration(milliseconds: 520), vsync: this, ); _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: _leapController, curve: Curves.easeOutBack, )); } @override void dispose() { _impactController.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; }); _triggerLayerTransition(); widget.onLayerChanged?.call(targetNode); _layerPageController.animateToPage( _currentLayerIndex, duration: const Duration(milliseconds: 420), curve: Curves.easeOutCubic, ); } 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(); }); _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); _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); final replyPost = await api.publishPost( body: _replyController.text.trim(), chainParentId: _currentFocusNode!.post.id, allowChain: true, ); _insertReplyPost(replyPost); _replyController.clear(); setState(() { _showInlineReply = false; _isPostingReply = false; }); _replyRevealController.reverse(); _impactController.forward().then((_) { _impactController.reset(); }); widget.onReplyPosted?.call(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Reply posted!'), backgroundColor: Colors.green, duration: Duration(seconds: 2), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to post reply: $e'), backgroundColor: Colors.red, ), ); } 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 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); }, ), 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( color: AppTheme.scaffoldBg.withValues(alpha: 0.05), ), ), ), ), ), ], ), ); } 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), ], ), ), if (hasChildren) _buildSatelliteListSliver(focusNode.children) else SliverFillRemaining( hasScrollBody: false, child: _buildEmptyDiscoveryState(focusNode), ), ], ); } 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( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: BoxDecoration( color: AppTheme.brightNavy, borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: AppTheme.brightNavy.withValues(alpha: 0.4), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ 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 Container( margin: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: GestureDetector( onTap: () { if (_layerStack.length > 1) { _scrubToLayer(_layerStack.length - 2); } }, child: Container( width: double.infinity, decoration: BoxDecoration( // Rounded top corners, flat bottom borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), // Temporary debug background color color: Colors.red.withValues(alpha: 0.1), // More prominent gradient from darker top to lighter bottom gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppTheme.navyBlue.withValues(alpha: 0.25), // Darker at top AppTheme.navyBlue.withValues(alpha: 0.12), // Lighter at bottom Colors.transparent, // Fade to transparent ], stops: const [0.0, 0.6, 1.0], ), // More prominent border at the top border: Border( top: BorderSide( color: AppTheme.brightNavy.withValues(alpha: 0.5), width: 2, ), ), ), child: Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), child: Row( children: [ _buildMiniAvatar(parentNode), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Previous chain', style: GoogleFonts.inter( color: AppTheme.textSecondary.withValues(alpha: 0.9), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.6, ), ), const SizedBox(height: 4), Text( parentNode.post.author?.displayName ?? 'Anonymous', style: GoogleFonts.inter( color: AppTheme.navyBlue.withValues(alpha: 0.95), fontSize: 13, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 3), Text( parentNode.post.body, maxLines: 2, overflow: TextOverflow.ellipsis, style: GoogleFonts.inter( color: AppTheme.navyText.withValues(alpha: 0.8), fontSize: 12, height: 1.3, fontWeight: FontWeight.w500, ), ), ], ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppTheme.brightNavy.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Icon( Icons.arrow_upward, size: 18, color: AppTheme.brightNavy, ), ), ], ), ), ), ), ).animate().fadeIn(duration: 220.ms).slideY(begin: -0.08, 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( _initialForName(node.post.author?.displayName), style: GoogleFonts.inter( color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w700, ), ), ) : 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 _buildFocusPost(ThreadNode node) { final isLoading = widget.isLoading; return Hero( tag: 'thread_post_${node.post.id}', child: Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 10), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(20), border: Border.all( color: AppTheme.brightNavy, width: 2.5, ), 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), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( child.post.author?.displayName ?? 'Anonymous', style: GoogleFonts.inter( color: AppTheme.textPrimary, fontSize: 12, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), Text( timeago.format(child.post.createdAt), style: GoogleFonts.inter( color: AppTheme.textSecondary, fontSize: 10, ), ), if (child.post.reactions != null && child.post.reactions!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: ReactionsDisplay( reactionCounts: child.post.reactions!, myReactions: child.post.myReactions?.toSet() ?? {}, mode: ReactionsDisplayMode.compact, padding: EdgeInsets.zero, ), ), ], ), ), 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 _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( 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), borderRadius: BorderRadius.circular(16), border: Border.all( color: AppTheme.navyBlue.withValues(alpha: 0.3), width: 2, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.reply, size: 20, color: AppTheme.navyBlue), const SizedBox(width: 8), Expanded( child: Text( 'Replying to ${_currentFocusNode?.post.author?.displayName ?? 'Anonymous'}', style: GoogleFonts.inter( fontSize: 14, color: AppTheme.navyBlue, fontWeight: FontWeight.w600, ), ), ), GestureDetector( 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... ', hintStyle: GoogleFonts.inter( fontSize: 14, color: AppTheme.textSecondary, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), filled: true, fillColor: AppTheme.cardSurface, contentPadding: const EdgeInsets.all(12), ), ), const SizedBox(height: 12), Row( children: [ Text( '${_replyController.text.length}/500', style: GoogleFonts.inter( fontSize: 12, color: AppTheme.textSecondary, ), ), const Spacer(), ElevatedButton( onPressed: (_replyController.text.trim().isNotEmpty && !_isPostingReply) ? _submitInlineReply : null, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.brightNavy, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), ), child: _isPostingReply ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Text( 'Reply', style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ], ), ], ), ), ), ), ); } } 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; } }