1503 lines
50 KiB
Dart
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;
|
|
}
|
|
}
|