sojorn/sojorn_app/lib/widgets/video_comments_sheet.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

425 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../models/post.dart';
import '../models/thread_node.dart';
import '../services/api_service.dart';
import '../theme/app_theme.dart';
import '../widgets/threaded_comment_widget.dart';
/// Draggable bottom sheet for video comments (TikTok-style)
class VideoCommentsSheet extends StatefulWidget {
final String postId;
final int initialCommentCount;
final VoidCallback? onCommentPosted;
const VideoCommentsSheet({
super.key,
required this.postId,
this.initialCommentCount = 0,
this.onCommentPosted,
});
@override
State<VideoCommentsSheet> createState() => _VideoCommentsSheetState();
}
class _VideoCommentsSheetState extends State<VideoCommentsSheet>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
List<Post> _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<void> _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<Color>(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<Color>(Colors.white),
),
)
: Icon(
Icons.send,
size: 16,
color: Colors.white,
),
),
),
],
),
),
);
}
Future<void> _postComment() async {
if (_commentController.text.trim().isEmpty) return;
setState(() => _isPostingComment = true);
try {
// TODO: Implement actual comment posting
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
_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),
),
);
}
}