**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.
425 lines
12 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|