sojorn/sojorn_app/lib/widgets/kinetic_thread_widget.dart
2026-01-30 20:56:57 -06:00

1503 lines
50 KiB
Dart

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';
/// 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<KineticThreadWidget> createState() => _KineticThreadWidgetState();
}
class _KineticThreadWidgetState extends ConsumerState<KineticThreadWidget>
with TickerProviderStateMixin {
ThreadNode? _currentFocusNode;
List<ThreadNode> _layerStack = [];
int _currentLayerIndex = 0;
final FocusNode _focusNode = FocusNode();
final FocusNode _replyFocusNode = FocusNode();
late AnimationController _impactController;
late AnimationController _replyRevealController;
late AnimationController _leapController;
late Animation<double> _replyRevealAnimation;
late Animation<Offset> _leapSlideAnimation;
late final PageController _layerPageController;
bool _showInlineReply = false;
final TextEditingController _replyController = TextEditingController();
bool _isPostingReply = false;
final Set<String> _collapsedRails = <String>{};
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<Offset>(
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<void> _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 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: [
_buildMiniAvatar(parentNode),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Previous chain',
style: GoogleFonts.inter(
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(
parentNode.post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
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,
),
],
),
),
),
),
),
).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(
_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, 6, 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<ThreadNode> _rankChildren(List<ThreadNode> children) {
final ranked = List<ThreadNode>.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<ThreadNode> 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,
),
),
],
),
),
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<Color>(Colors.white),
),
)
: Text(
'Reply',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
),
);
}
}
class _KineticScrubberHeader extends SliverPersistentHeaderDelegate {
final List<ThreadNode> layerStack;
final int currentIndex;
final int? totalCount;
final VoidCallback? onRefreshRequested;
final VoidCallback onScrubStart;
final VoidCallback onScrubEnd;
final ValueChanged<int> 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;
}
}