From ca59f3286aa2c560ab7d7bd91bb672fead1298ee Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 30 Jan 2026 10:03:15 -0600 Subject: [PATCH] Chain updates --- .../post/threaded_conversation_screen.dart | 193 +--- .../lib/widgets/glassmorphic_quips_sheet.dart | 907 ++++++++++++++++++ .../lib/widgets/kinetic_thread_widget.dart | 805 ++++++++++++++++ .../lib/widgets/video_comments_sheet.dart | 396 +------- sojorn_app/pubspec.lock | 16 + sojorn_app/pubspec.yaml | 1 + 6 files changed, 1756 insertions(+), 562 deletions(-) create mode 100644 sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart create mode 100644 sojorn_app/lib/widgets/kinetic_thread_widget.dart diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index 1c8eb1f..c627673 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -5,10 +5,9 @@ import '../../models/post.dart'; import '../../models/thread_node.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; -import '../../widgets/threaded_comment_widget.dart'; -import '../compose/compose_screen.dart'; +import '../../widgets/kinetic_thread_widget.dart'; -/// Screen for displaying threaded conversations (Reddit-style) +/// Screen for displaying threaded conversations with Kinetic Spatial Engine class ThreadedConversationScreen extends ConsumerStatefulWidget { final String rootPostId; final Post? rootPost; @@ -161,164 +160,30 @@ class _ThreadedConversationScreenState extends ConsumerState maxDepth ? currentDepth : maxDepth; - for (final child in node.children) { - traverse(child, currentDepth + 1); - } - } - - for (final child in _threadTree!.children) { - traverse(child, 1); - } - - return maxDepth; + void _handleLayerChange(ThreadNode newFocusNode) { + // Update app bar title to reflect current layer + setState(() {}); } void _handleReply() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ComposeScreen( - chainParentPost: widget.rootPost, - ), - ), - ); + // This is now handled inline by the KineticThreadWidget + // Keeping for compatibility } void _handleLike() { @@ -330,26 +195,4 @@ class _ThreadedConversationScreenState extends ConsumerState createState() => _GlassmorphicQuipsSheetState(); +} + +class _GlassmorphicQuipsSheetState extends ConsumerState + with SingleTickerProviderStateMixin { + + late AnimationController _glassController; + late Animation _glassAnimation; + late Animation _blurAnimation; + + List _quips = []; + ThreadNode? _threadTree; + bool _isLoading = true; + String? _error; + final TextEditingController _quipController = TextEditingController(); + bool _isPostingQuip = false; + + // Expanded quip state + String? _expandedQuipId; + ThreadNode? _expandedThreadNode; + + @override + void initState() { + super.initState(); + + _glassController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + _glassAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _glassController, + curve: Curves.easeOutCubic, + )); + + _blurAnimation = Tween( + begin: 0.0, + end: 10.0, + ).animate(CurvedAnimation( + parent: _glassController, + curve: Curves.easeOutCubic, + )); + + _loadQuips(); + } + + @override + void dispose() { + _glassController.dispose(); + _quipController.dispose(); + super.dispose(); + } + + Future _loadQuips() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final api = ref.read(apiServiceProvider); + final quips = await api.getPostChain(widget.postId); + + if (mounted) { + setState(() { + _quips = quips; + if (quips.isNotEmpty) { + _threadTree = ThreadNode.buildTree(quips); + } + _isLoading = false; + }); + _glassController.forward(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _postQuip() async { + if (_quipController.text.trim().isEmpty) return; + + setState(() => _isPostingQuip = true); + + try { + final api = ref.read(apiServiceProvider); + await api.publishComment( + postId: widget.postId, + body: _quipController.text.trim(), + ); + + _quipController.clear(); + + // Refresh quips + await _loadQuips(); + + widget.onQuipPosted?.call(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Quip posted!'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to post quip: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isPostingQuip = false); + } + } + } + + void _expandQuip(ThreadNode node) { + setState(() { + _expandedQuipId = node.post.id; + _expandedThreadNode = node; + }); + } + + void _collapseQuip() { + setState(() { + _expandedQuipId = null; + _expandedThreadNode = null; + }); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.95, + snap: true, + snapSizes: const [0.6, 0.8, 0.95], + builder: (context, scrollController) { + return AnimatedBuilder( + animation: _glassController, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg.withValues(alpha: 0.9 + (0.1 * _glassAnimation.value)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3 * _glassAnimation.value), + blurRadius: 30, + offset: const Offset(0, -10), + ), + ], + border: Border.all( + color: Colors.white.withValues(alpha: 0.1 * _glassAnimation.value), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: _blurAnimation.value, + sigmaY: _blurAnimation.value, + ), + child: Column( + children: [ + // Glassmorphic drag handle + _buildGlassDragHandle(), + + // HUD-style header + _buildHUDHeader(), + + // Main content area + Expanded( + child: _buildMainContent(scrollController), + ), + + // Glassmorphic quip input + _buildGlassQuipInput(), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildGlassDragHandle() { + return Container( + margin: const EdgeInsets.symmetric(vertical: 16), + child: Container( + width: 48, + height: 5, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: Colors.white.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + ), + ); + } + + Widget _buildHUDHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + ), + child: Row( + children: [ + // HUD-style title + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.brightNavy.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 16, + color: AppTheme.brightNavy, + ), + const SizedBox(width: 6), + Text( + 'QUIPS', + style: GoogleFonts.inter( + color: AppTheme.brightNavy, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ], + ), + ), + + const Spacer(), + + // Animated count + AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Text( + '${(_threadTree?.totalCount ?? widget.initialQuipCount)}', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + + const SizedBox(width: 12), + + // Refresh button + GestureDetector( + onTap: _loadQuips, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.refresh, + size: 18, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMainContent(ScrollController scrollController) { + if (_isLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppTheme.brightNavy), + ), + ), + const SizedBox(height: 16), + Text( + 'Loading quips...', + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 14, + ), + ), + ], + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Colors.red.withValues(alpha: 0.8), + ), + const SizedBox(height: 16), + Text( + 'Failed to load quips', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadQuips, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.withValues(alpha: 0.8), + foregroundColor: Colors.white, + ), + child: Text('Try Again'), + ), + ], + ), + ), + ), + ); + } + + if (_threadTree == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.chat_bubble_outline, + size: 48, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 16), + Text( + 'No quips yet', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + 'Be the first to drop a quip!', + style: GoogleFonts.inter( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + // If we have an expanded quip, show the kinetic thread widget + if (_expandedThreadNode != null) { + return Column( + children: [ + // Back button + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + GestureDetector( + onTap: _collapseQuip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_back, + size: 18, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + 'Back to Quips', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Kinetic thread view + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: KineticThreadWidget( + rootNode: _expandedThreadNode!, + onReplyPosted: _loadQuips, + ), + ), + ), + ), + ], + ); + } + + // Show glassmorphic quip cards + return FadeTransition( + opacity: _glassAnimation, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _threadTree!.children.length, + itemBuilder: (context, index) { + final child = _threadTree!.children[index]; + return _buildGlassQuipCard(child, index); + }, + ), + ); + } + + Widget _buildGlassQuipCard(ThreadNode node, int index) { + final isExpanded = _expandedQuipId == node.post.id; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(bottom: 12), + transform: Matrix4.translationValues(0, 0, 0), + child: GestureDetector( + onTap: () => _expandQuip(node), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.white.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Author row + Row( + children: [ + // Glassmorphic avatar + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.brightNavy.withValues(alpha: 0.3), + AppTheme.egyptianBlue.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Center( + child: Text( + 'L${node.depth}', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 12), + + // Author info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + Text( + timeago.format(node.post.createdAt), + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + ], + ), + ), + + // Reply count indicator + if (node.hasChildren) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.egyptianBlue.withValues(alpha: 0.4), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.subdirectory_arrow_right, + size: 12, + color: AppTheme.egyptianBlue, + ), + const SizedBox(width: 4), + Text( + '${node.totalDescendants}', + style: GoogleFonts.inter( + color: AppTheme.egyptianBlue, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Quip content + Text( + node.post.body, + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 15, + height: 1.4, + ), + maxLines: isExpanded ? null : 3, + overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + + // Media if present + if (node.post.imageUrl != null) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SignedMediaImage( + url: node.post.imageUrl!, + width: double.infinity, + height: 150, + fit: BoxFit.cover, + ), + ), + ], + + if (isExpanded && node.hasChildren) ...[ + const SizedBox(height: 16), + + // Nested replies preview + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thread Replies', + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...node.children.take(3).map((child) => + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + 'L${child.depth}', + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + child.post.body, + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ).toList(), + + if (node.children.length > 3) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '+${node.children.length - 3} more replies', + style: GoogleFonts.inter( + color: AppTheme.egyptianBlue, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 8), + + // Action buttons + Row( + children: [ + GestureDetector( + onTap: () => _expandQuip(node), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 16, + color: Colors.white.withValues(alpha: 0.8), + ), + const SizedBox(width: 4), + Text( + isExpanded ? 'Collapse' : 'Expand Thread', + style: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildGlassQuipInput() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + border: Border( + top: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: TextField( + controller: _quipController, + maxLines: 2, + minLines: 1, + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Drop a quip...', + hintStyle: GoogleFonts.inter( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: FloatingActionButton( + onPressed: (_quipController.text.trim().isNotEmpty && !_isPostingQuip) + ? _postQuip + : null, + backgroundColor: _quipController.text.isNotEmpty + ? AppTheme.brightNavy.withValues(alpha: 0.9) + : Colors.white.withValues(alpha: 0.2), + mini: true, + child: _isPostingQuip + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Icon( + Icons.send, + size: 16, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/sojorn_app/lib/widgets/kinetic_thread_widget.dart b/sojorn_app/lib/widgets/kinetic_thread_widget.dart new file mode 100644 index 0000000..8e19845 --- /dev/null +++ b/sojorn_app/lib/widgets/kinetic_thread_widget.dart @@ -0,0 +1,805 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/post.dart'; +import '../models/thread_node.dart'; +import '../providers/api_provider.dart'; +import '../theme/app_theme.dart'; +import '../widgets/media/signed_media_image.dart'; +import '../widgets/post/post_actions.dart'; + +/// Kinetic Spatial Engine widget for layer-based thread navigation +class KineticThreadWidget extends ConsumerStatefulWidget { + final ThreadNode rootNode; + final Function(ThreadNode)? onLayerChanged; + final Function()? onReplyPosted; + + const KineticThreadWidget({ + super.key, + required this.rootNode, + this.onLayerChanged, + this.onReplyPosted, + }); + + @override + ConsumerState createState() => _KineticThreadWidgetState(); +} + +class _KineticThreadWidgetState extends ConsumerState + with TickerProviderStateMixin { + + // Layer navigation state + ThreadNode? _currentFocusNode; + List _layerStack = []; + final PageController _pageController = PageController(); + + // Animation controllers + late AnimationController _layerTransitionController; + late AnimationController _lineageBarController; + late AnimationController _impactController; + + // Animations + late Animation _slideAnimation; + late Animation _scaleAnimation; + late Animation _fadeAnimation; + + // Reply state + bool _showInlineReply = false; + final TextEditingController _replyController = TextEditingController(); + bool _isPostingReply = false; + + // Rail collapsing state + final Set _collapsedRails = {}; + + @override + void initState() { + super.initState(); + _currentFocusNode = widget.rootNode; + _layerStack = [widget.rootNode]; + + _initializeAnimations(); + } + + void _initializeAnimations() { + // Layer transition animations + _layerTransitionController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _layerTransitionController, + curve: Curves.easeOutCubic, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _layerTransitionController, + curve: Curves.easeOutBack, + )); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _layerTransitionController, + curve: Curves.easeInOut, + )); + + // Lineage bar animation + _lineageBarController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + // Impact animation for new replies + _impactController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + } + + @override + void dispose() { + _layerTransitionController.dispose(); + _lineageBarController.dispose(); + _impactController.dispose(); + _pageController.dispose(); + _replyController.dispose(); + super.dispose(); + } + + void _drillDownToLayer(ThreadNode targetNode) { + setState(() { + _currentFocusNode = targetNode; + _layerStack.add(targetNode); + _showInlineReply = false; + }); + + _layerTransitionController.forward().then((_) { + _layerTransitionController.reset(); + }); + + _lineageBarController.forward(); + widget.onLayerChanged?.call(targetNode); + } + + void _scrubToLayer(int layerIndex) { + if (layerIndex < 0 || layerIndex >= _layerStack.length) return; + + setState(() { + _currentFocusNode = _layerStack[layerIndex]; + _layerStack = _layerStack.sublist(0, layerIndex + 1); + _showInlineReply = false; + }); + + _layerTransitionController.forward().then((_) { + _layerTransitionController.reset(); + }); + + widget.onLayerChanged?.call(_currentFocusNode!); + } + + void _toggleRailCollapse(ThreadNode node) { + setState(() { + if (_collapsedRails.contains(node.post.id)) { + _collapsedRails.remove(node.post.id); + } else { + _collapsedRails.add(node.post.id); + + // Shatter animation + _impactController.forward().then((_) { + _impactController.reset(); + }); + } + }); + } + + bool _isRailCollapsed(ThreadNode node) { + return _collapsedRails.contains(node.post.id); + } + + Future _submitInlineReply() async { + if (_replyController.text.trim().isEmpty || _currentFocusNode == null) return; + + setState(() => _isPostingReply = true); + + try { + final api = ref.read(apiServiceProvider); + await api.publishComment( + postId: _currentFocusNode!.post.id, + body: _replyController.text.trim(), + ); + + _replyController.clear(); + setState(() { + _showInlineReply = false; + _isPostingReply = false; + }); + + // Impact animation + _impactController.forward().then((_) { + _impactController.reset(); + }); + + widget.onReplyPosted?.call(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Reply posted!'), + backgroundColor: Colors.green, + duration: const 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); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Kinetic Trace Bar (Lineage Scrubber) + _buildKineticTraceBar(), + + // Main content area with layer navigation + Expanded( + child: _buildLayerContent(), + ), + + // Inline reply composer + if (_showInlineReply && _currentFocusNode != null) + _buildInlineReplyComposer(), + ], + ); + } + + Widget _buildKineticTraceBar() { + return AnimatedBuilder( + animation: _lineageBarController, + builder: (context, child) { + return Container( + height: 60, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + // Back button + if (_layerStack.length > 1) + GestureDetector( + onTap: () => _scrubToLayer(_layerStack.length - 2), + child: Container( + margin: const EdgeInsets.only(left: 12), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.arrow_back, + size: 20, + color: AppTheme.brightNavy, + ), + ), + ), + + // Lineage avatars + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _layerStack.length, + itemBuilder: (context, index) { + final node = _layerStack[index]; + final isCurrentLayer = index == _layerStack.length - 1; + + return GestureDetector( + onTap: () => _scrubToLayer(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isCurrentLayer + ? AppTheme.brightNavy + : AppTheme.navyBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: isCurrentLayer + ? null + : Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Mini avatar + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isCurrentLayer + ? Colors.white + : AppTheme.brightNavy.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + 'L${node.depth}', + style: GoogleFonts.inter( + color: isCurrentLayer + ? AppTheme.brightNavy + : AppTheme.brightNavy, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 6), + // Author name + Text( + node.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: isCurrentLayer + ? Colors.white + : AppTheme.navyBlue, + fontSize: 12, + fontWeight: isCurrentLayer + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildLayerContent() { + if (_currentFocusNode == null) { + return const Center(child: Text('No content')); + } + + return PageView.builder( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), // Disable manual swiping + itemCount: 1, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _layerTransitionController, + builder: (context, child) { + return SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: _buildFocusLayer(_currentFocusNode!), + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildFocusLayer(ThreadNode focusNode) { + return Column( + children: [ + // Focus post (blooms to full width) + _buildFocusPost(focusNode), + + // Children list (high-velocity scroll) + if (focusNode.hasChildren) + Expanded( + child: _buildChildrenList(focusNode.children), + ), + ], + ); + } + + Widget _buildFocusPost(ThreadNode node) { + return Hero( + tag: 'post_${node.post.id}', + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.2), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Author and metadata + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + 'L${node.depth}', + style: GoogleFonts.inter( + color: AppTheme.brightNavy, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + timeago.format(node.post.createdAt), + style: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + + // Post content + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + node.post.body, + style: GoogleFonts.inter( + fontSize: 16, + color: AppTheme.navyText, + height: 1.5, + ), + ), + ), + + // Media if present + if (node.post.imageUrl != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SignedMediaImage( + url: node.post.imageUrl!, + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ), + ), + ), + ], + + // Actions + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => setState(() => _showInlineReply = !_showInlineReply), + icon: Icon(Icons.reply, size: 18), + label: Text('Reply'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.brightNavy, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + if (node.hasChildren) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 16, + color: AppTheme.egyptianBlue, + ), + const SizedBox(width: 4), + Text( + '${node.totalDescendants}', + style: GoogleFonts.inter( + color: AppTheme.egyptianBlue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildChildrenList(List children) { + // Filter out collapsed rails + final visibleChildren = children.where((child) => !_isRailCollapsed(child)).toList(); + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: visibleChildren.length, + itemBuilder: (context, index) { + final child = visibleChildren[index]; + return _buildChildItem(child, index); + }, + ); + } + + Widget _buildChildItem(ThreadNode child, int index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(bottom: 12), + transform: Matrix4.translationValues(0, 0, 0), + child: GestureDetector( + onTap: () => _drillDownToLayer(child), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + children: [ + // Power Rail (collapsible thread indicator) + GestureDetector( + onLongPress: () => _toggleRailCollapse(child), + onDoubleTap: () => _toggleRailCollapse(child), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 4, + height: 40, + decoration: BoxDecoration( + color: _isRailCollapsed(child) + ? Colors.red.withValues(alpha: 0.5) + : AppTheme.brightNavy.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(width: 12), + + // Child content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + 'L${child.depth}', + style: GoogleFonts.inter( + color: AppTheme.brightNavy, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + child.post.author?.displayName ?? 'Anonymous', + style: GoogleFonts.inter( + color: AppTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (child.hasChildren) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.egyptianBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${child.totalDescendants}', + style: GoogleFonts.inter( + color: AppTheme.egyptianBlue, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + child.post.body, + style: GoogleFonts.inter( + color: AppTheme.navyText, + fontSize: 14, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + // Drill down indicator + Icon( + Icons.arrow_forward_ios, + size: 16, + color: AppTheme.textSecondary, + ), + ], + ), + ), + ), + ); + } + + Widget _buildInlineReplyComposer() { + return AnimatedBuilder( + animation: _impactController, + builder: (context, child) { + return Transform.scale( + scale: 1.0 + (_impactController.value * 0.05), + child: Container( + margin: const EdgeInsets.all(16), + 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), + Text( + 'Replying to ${_currentFocusNode!.post.author?.displayName ?? 'Anonymous'}', + style: GoogleFonts.inter( + fontSize: 14, + color: AppTheme.navyBlue, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _showInlineReply = false), + child: Icon(Icons.close, size: 20, color: AppTheme.navyBlue), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _replyController, + maxLines: 3, + minLines: 1, + style: GoogleFonts.inter( + fontSize: 14, + color: AppTheme.navyText, + ), + decoration: InputDecoration( + hintText: 'Write your reply...', + hintStyle: GoogleFonts.inter( + fontSize: 14, + color: AppTheme.textSecondary, + ), + border: 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 + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Reply', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/sojorn_app/lib/widgets/video_comments_sheet.dart b/sojorn_app/lib/widgets/video_comments_sheet.dart index 2d0d874..eb76b4f 100644 --- a/sojorn_app/lib/widgets/video_comments_sheet.dart +++ b/sojorn_app/lib/widgets/video_comments_sheet.dart @@ -5,9 +5,9 @@ import '../models/post.dart'; import '../models/thread_node.dart'; import '../services/api_service.dart'; import '../theme/app_theme.dart'; -import '../widgets/threaded_comment_widget.dart'; +import '../widgets/glassmorphic_quips_sheet.dart'; -/// Draggable bottom sheet for video comments (TikTok-style) +/// Glassmorphic video comments sheet with kinetic navigation class VideoCommentsSheet extends StatefulWidget { final String postId; final int initialCommentCount; @@ -24,404 +24,26 @@ class VideoCommentsSheet extends StatefulWidget { State createState() => _VideoCommentsSheetState(); } -class _VideoCommentsSheetState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _animation; +class _VideoCommentsSheetState extends State { + // This is now just a wrapper around GlassmorphicQuipsSheet + // All functionality is delegated to the glassmorphic sheet - List _comments = []; - ThreadNode? _threadTree; - bool _isLoading = true; - String? _error; - final TextEditingController _commentController = TextEditingController(); - bool _isPostingComment = false; - @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - ); - _animation = CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - ); - _loadComments(); } @override void dispose() { - _animationController.dispose(); - _commentController.dispose(); super.dispose(); } - Future _loadComments() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final comments = await ApiService.instance.getPostChain(widget.postId); - - if (mounted) { - setState(() { - _comments = comments; - if (comments.isNotEmpty) { - _threadTree = ThreadNode.buildTree(comments); - } - _isLoading = false; - }); - _animationController.forward(); - } - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - } - @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.5, // Start at 50% screen height - minChildSize: 0.3, // Minimum 30% - maxChildSize: 0.95, // Maximum 95% - snap: true, - snapSizes: const [0.5, 0.8, 0.95], - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: AppTheme.scaffoldBg, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - offset: const Offset(0, -5), - ), - ], - ), - child: Column( - children: [ - // Drag handle - _buildDragHandle(), - - // Header with comment count - _buildHeader(), - - // Comments list or loading/error state - Expanded( - child: _buildCommentsList(scrollController), - ), - - // Comment input - _buildCommentInput(), - ], - ), - ); - }, - ); - } - - Widget _buildDragHandle() { - return Container( - margin: const EdgeInsets.symmetric(vertical: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textDisabled, - borderRadius: BorderRadius.circular(2), - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - 'Comments', - style: GoogleFonts.literata( - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - fontSize: 18, - ), - ), - const SizedBox(width: 8), - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1 * _animation.value), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${(_threadTree?.totalCount ?? widget.initialCommentCount)}', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ); - }, - ), - const Spacer(), - IconButton( - onPressed: _loadComments, - icon: Icon(Icons.refresh, color: AppTheme.navyBlue), - tooltip: 'Refresh comments', - ), - ], - ), - ); - } - - Widget _buildCommentsList(ScrollController scrollController) { - if (_isLoading) { - return Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppTheme.brightNavy), - ), - ); - } - - if (_error != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Colors.red[400], - ), - const SizedBox(height: 16), - Text( - 'Failed to load comments', - style: GoogleFonts.literata( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - ), - ), - const SizedBox(height: 8), - Text( - _error!, - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadComments, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.brightNavy, - foregroundColor: Colors.white, - ), - child: Text('Try Again'), - ), - ], - ), - ), - ); - } - - if (_threadTree == null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 48, - color: AppTheme.textDisabled, - ), - const SizedBox(height: 16), - Text( - 'No comments yet', - style: GoogleFonts.literata( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - ), - ), - const SizedBox(height: 8), - Text( - 'Be the first to comment!', - style: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ); - } - - return FadeTransition( - opacity: _animation, - child: RefreshIndicator( - onRefresh: _loadComments, - color: AppTheme.brightNavy, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: 1, // We render the entire tree in one widget - itemBuilder: (context, index) { - return ThreadedCommentWidget( - node: _threadTree!, - onReply: _handleReply, - onLike: _handleLike, - isRootPost: true, - ); - }, - ), - ), - ); - } - - Widget _buildCommentInput() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - border: Border( - top: BorderSide( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - width: 1, - ), - ), - ), - child: SafeArea( - child: Row( - children: [ - Expanded( - child: TextField( - controller: _commentController, - maxLines: 1, - decoration: InputDecoration( - hintText: 'Add a comment...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: AppTheme.navyBlue.withValues(alpha: 0.05), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - ), - ), - ), - const SizedBox(width: 8), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FloatingActionButton( - onPressed: _isPostingComment ? null : _postComment, - backgroundColor: _commentController.text.isNotEmpty - ? AppTheme.brightNavy - : AppTheme.textDisabled, - mini: true, - child: _isPostingComment - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Icon( - Icons.send, - size: 16, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ); - } - - Future _postComment() async { - if (_commentController.text.trim().isEmpty) return; - - setState(() => _isPostingComment = true); - - try { - // Post comment using Go API - await ApiService.instance.publishComment( - postId: widget.postId, - body: _commentController.text.trim(), - ); - - _commentController.clear(); - - // Refresh comments - await _loadComments(); - - // Notify parent if callback provided - widget.onCommentPosted?.call(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Comment posted!'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to post comment: $e'), - backgroundColor: Colors.red, - ), - ); - } - } finally { - if (mounted) { - setState(() => _isPostingComment = false); - } - } - } - - void _handleReply() { - // TODO: Implement reply functionality - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Reply functionality coming soon!'), - duration: Duration(seconds: 2), - ), - ); - } - - void _handleLike() { - // TODO: Implement like functionality - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Like functionality coming soon!'), - duration: Duration(seconds: 2), - ), + return GlassmorphicQuipsSheet( + postId: widget.postId, + initialQuipCount: widget.initialCommentCount, + onQuipPosted: widget.onCommentPosted, ); } } diff --git a/sojorn_app/pubspec.lock b/sojorn_app/pubspec.lock index ea4c11c..9bd69ee 100644 --- a/sojorn_app/pubspec.lock +++ b/sojorn_app/pubspec.lock @@ -430,6 +430,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" flutter_colorpicker: dependency: "direct main" description: @@ -715,6 +723,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" flutter_test: dependency: "direct dev" description: flutter diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index ba59103..746f7dc 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: video_player: ^2.10.1 visibility_detector: ^0.4.0+2 ffmpeg_kit_flutter_new: ^4.1.0 + flutter_animate: ^4.5.0 # Rich Text Editor flutter_quill: ^11.0.0