From 0a5b89dd6a1a7ef04c8c13b9f8a66f5cf8046c9e Mon Sep 17 00:00:00 2001 From: sBubshait Date: Sun, 27 Jul 2025 13:14:42 +0300 Subject: [PATCH] feat: commenting on posts --- frontend/lib/models/comment_models.dart | 109 ++++ .../lib/screens/pages/post_view_page.dart | 16 +- frontend/lib/services/post_service.dart | 114 ++++ frontend/lib/widgets/comments_widget.dart | 525 ++++++++++++++++++ 4 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/models/comment_models.dart create mode 100644 frontend/lib/widgets/comments_widget.dart diff --git a/frontend/lib/models/comment_models.dart b/frontend/lib/models/comment_models.dart new file mode 100644 index 0000000..5970514 --- /dev/null +++ b/frontend/lib/models/comment_models.dart @@ -0,0 +1,109 @@ +class CommentUser { + final String id; + final String displayName; + + CommentUser({ + required this.id, + required this.displayName, + }); + + factory CommentUser.fromJson(Map json) { + return CommentUser( + id: json['id'].toString(), + displayName: json['displayName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + }; + } +} + +class Comment { + final String id; + final String postId; + final CommentUser creator; + final String body; + final DateTime creationDate; + final String? replyComment; + final String? displayReplyComment; + final CommentUser? replyUser; + final int level; + final List replies; + + Comment({ + required this.id, + required this.postId, + required this.creator, + required this.body, + required this.creationDate, + this.replyComment, + this.displayReplyComment, + this.replyUser, + required this.level, + required this.replies, + }); + + factory Comment.fromJson(Map json) { + return Comment( + id: json['id'].toString(), + postId: json['postId'].toString(), + creator: CommentUser.fromJson(json['creator']), + body: json['body'] ?? '', + creationDate: DateTime.parse(json['creationDate']), + replyComment: json['replyComment']?.toString(), + displayReplyComment: json['displayReplyComment']?.toString(), + replyUser: json['replyUser'] != null ? CommentUser.fromJson(json['replyUser']) : null, + level: json['level'] ?? 1, + replies: (json['replies'] as List?)?.map((reply) => Comment.fromJson(reply)).toList() ?? [], + ); + } + + Map toJson() { + return { + 'id': id, + 'postId': postId, + 'creator': creator.toJson(), + 'body': body, + 'creationDate': creationDate.toIso8601String(), + 'replyComment': replyComment, + 'displayReplyComment': displayReplyComment, + 'replyUser': replyUser?.toJson(), + 'level': level, + 'replies': replies.map((reply) => reply.toJson()).toList(), + }; + } +} + +class CommentsResponse { + final bool status; + final String? message; + final List data; + + CommentsResponse({ + required this.status, + this.message, + required this.data, + }); + + factory CommentsResponse.fromJson(Map json) { + return CommentsResponse( + status: json['status'] ?? false, + message: json['message'], + data: json['data'] != null + ? (json['data'] as List).map((comment) => Comment.fromJson(comment)).toList() + : [], + ); + } + + Map toJson() { + return { + 'status': status, + 'message': message, + 'data': data.map((comment) => comment.toJson()).toList(), + }; + } +} \ No newline at end of file diff --git a/frontend/lib/screens/pages/post_view_page.dart b/frontend/lib/screens/pages/post_view_page.dart index 65860a6..060f8c2 100644 --- a/frontend/lib/screens/pages/post_view_page.dart +++ b/frontend/lib/screens/pages/post_view_page.dart @@ -3,6 +3,7 @@ import 'package:share_plus/share_plus.dart'; import '../../models/post_models.dart'; import '../../services/post_service.dart'; import '../../utils/invitation_utils.dart'; +import '../../widgets/comments_widget.dart'; class PostViewPage extends StatefulWidget { final String postId; @@ -147,9 +148,10 @@ class _PostViewPageState extends State { final avatarColor = _getAvatarColor(user.displayName); final avatarLetter = _getAvatarLetter(user.displayName); - return Container( - margin: EdgeInsets.only( - right: index < displayUsers.length - 1 ? -8 : 0, + return Transform.translate( + offset: Offset( + index < displayUsers.length - 1 ? -8 : 0, + 0, ), child: CircleAvatar( radius: 16, @@ -639,7 +641,13 @@ class _PostViewPageState extends State { SizedBox(height: 8), _buildLikedBySection(), SizedBox(height: 16), - _buildMockComments(), + Container( + height: 400, + child: CommentsWidget( + postId: widget.postId, + commentCount: _post?.comments ?? 0, + ), + ), SizedBox(height: 24), ], ), diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index 9afe15a..f1eeced 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import '../models/post_models.dart'; +import '../models/comment_models.dart'; import 'http_service.dart'; import 'auth_service.dart'; @@ -198,4 +199,117 @@ class PostService { return {'success': false, 'message': 'Network error: $e', 'post': null}; } } + + static Future> getComments(String postId) async { + try { + final response = await HttpService.get('/posts/comments/list?postId=$postId'); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + final commentsResponse = CommentsResponse.fromJson(responseData); + + if (commentsResponse.status) { + return { + 'success': true, + 'comments': commentsResponse.data, + }; + } else { + return { + 'success': false, + 'message': commentsResponse.message ?? 'Failed to fetch comments', + 'comments': [], + }; + } + } else if (response.statusCode == 401 || response.statusCode == 403) { + await AuthService.handleAuthenticationError(); + return { + 'success': false, + 'message': 'Session expired. Please login again.', + 'comments': [], + }; + } else { + try { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to fetch comments', + 'comments': [], + }; + } catch (e) { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + 'comments': [], + }; + } + } + } catch (e) { + print('Error fetching comments: $e'); + return { + 'success': false, + 'message': 'Network error: $e', + 'comments': [], + }; + } + } + + static Future> createComment({ + required String postId, + required String body, + String? replyComment, + }) async { + try { + final requestBody = { + 'postId': int.parse(postId), + 'body': body, + }; + + if (replyComment != null) { + requestBody['replyComment'] = int.parse(replyComment); + } + + final response = await HttpService.post('/posts/comments/create', requestBody); + + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = jsonDecode(response.body); + + if (responseData['status'] == true) { + return { + 'success': true, + 'comment': Comment.fromJson(responseData['data']), + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to create comment', + }; + } + } else if (response.statusCode == 401 || response.statusCode == 403) { + await AuthService.handleAuthenticationError(); + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else { + try { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to create comment', + }; + } catch (e) { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } + } catch (e) { + print('Error creating comment: $e'); + return { + 'success': false, + 'message': 'Network error: $e', + }; + } + } } diff --git a/frontend/lib/widgets/comments_widget.dart b/frontend/lib/widgets/comments_widget.dart new file mode 100644 index 0000000..244b486 --- /dev/null +++ b/frontend/lib/widgets/comments_widget.dart @@ -0,0 +1,525 @@ +import 'package:flutter/material.dart'; +import '../models/comment_models.dart'; +import '../services/post_service.dart'; + +class CommentsWidget extends StatefulWidget { + final String postId; + final int commentCount; + + const CommentsWidget({ + Key? key, + required this.postId, + required this.commentCount, + }) : super(key: key); + + @override + _CommentsWidgetState createState() => _CommentsWidgetState(); +} + +class _CommentsWidgetState extends State { + List comments = []; + bool isLoading = true; + String? errorMessage; + final TextEditingController _commentController = TextEditingController(); + bool _isCreatingComment = false; + Comment? _replyingTo; + + @override + void initState() { + super.initState(); + _loadComments(); + } + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); + } + + Future _loadComments() async { + setState(() { + isLoading = true; + errorMessage = null; + }); + + final result = await PostService.getComments(widget.postId); + + setState(() { + isLoading = false; + if (result['success'] == true) { + comments = result['comments'] as List; + } else { + errorMessage = result['message'] ?? 'Failed to load comments'; + comments = []; + } + }); + } + + void _setReplyingTo(Comment? comment) { + setState(() { + _replyingTo = comment; + if (comment == null) { + _commentController.clear(); + } + }); + } + + Future _createComment() async { + final body = _commentController.text.trim(); + if (body.isEmpty || _isCreatingComment) return; + + setState(() { + _isCreatingComment = true; + }); + + try { + final result = await PostService.createComment( + postId: widget.postId, + body: body, + replyComment: _replyingTo?.id, + ); + + if (result['success'] == true) { + _commentController.clear(); + _setReplyingTo(null); + await _loadComments(); // Reload comments to show the new one + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to create comment'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Network error occurred'), + backgroundColor: Colors.red, + ), + ); + } finally { + setState(() { + _isCreatingComment = false; + }); + } + } + + String _formatTimeAgo(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return 'Just now'; + } + } + + Widget _buildComment(Comment comment, {double leftPadding = 0}) { + final safePadding = leftPadding < 0 ? 0.0 : leftPadding; + return Container( + margin: EdgeInsets.only(left: safePadding, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + CircleAvatar( + radius: 16, + backgroundColor: Color(0xFF6A4C93), + child: Text( + comment.creator.displayName.isNotEmpty + ? comment.creator.displayName[0].toUpperCase() + : 'U', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(width: 8), + + // Comment content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with name and time + Row( + children: [ + Text( + comment.creator.displayName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.black87, + ), + ), + SizedBox(width: 8), + Text( + _formatTimeAgo(comment.creationDate), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + SizedBox(height: 4), + + // Reply indicator if this is a reply + if (comment.replyUser != null) ...[ + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Replying to ${comment.replyUser!.displayName}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ), + SizedBox(height: 6), + ], + + // Comment body + Text( + comment.body, + style: TextStyle( + fontSize: 14, + color: Colors.black87, + height: 1.3, + ), + ), + SizedBox(height: 8), + + // Reply button + InkWell( + onTap: () => _setReplyingTo(comment), + child: Text( + 'Reply', + style: TextStyle( + fontSize: 12, + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + + // Replies + if (comment.replies.isNotEmpty) ...[ + SizedBox(height: 12), + ...comment.replies.map( + (reply) => _buildComment(reply, leftPadding: safePadding + 24), + ), + ], + ], + ), + ); + } + + Widget _buildErrorState() { + return Container( + padding: EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.grey[400]), + SizedBox(height: 16), + Text( + 'Failed to load comments', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + SizedBox(height: 8), + Text( + errorMessage ?? 'Unknown error occurred', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadComments, + icon: Icon(Icons.refresh), + label: Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Container( + padding: EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), + SizedBox(height: 16), + Text( + 'No comments yet', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + SizedBox(height: 8), + Text( + 'Be the first to share your thoughts!', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + // Header + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.grey[200]!)), + ), + child: Row( + children: [ + Text( + 'Comments', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + if (widget.commentCount > 0) ...[ + SizedBox(width: 8), + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Color(0xFF6A4C93).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${widget.commentCount}', + style: TextStyle( + fontSize: 12, + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + Spacer(), + if (isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ), + ], + ), + ), + + // Content + Expanded( + child: isLoading + ? Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ) + : errorMessage != null + ? _buildErrorState() + : comments.isEmpty + ? _buildEmptyState() + : ListView.builder( + padding: EdgeInsets.only(left: 16, right: 16, top: 16), + itemCount: comments.length, + itemBuilder: (context, index) => + _buildComment(comments[index]), + ), + ), + + // Comment input section + Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey[200]!)), + ), + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Reply indicator + if (_replyingTo != null) ...[ + Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Color(0xFF6A4C93).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.reply, size: 16, color: Color(0xFF6A4C93)), + SizedBox(width: 8), + Expanded( + child: Text( + 'Replying to ${_replyingTo!.creator.displayName}', + style: TextStyle( + fontSize: 12, + color: Color(0xFF6A4C93), + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + onPressed: () => _setReplyingTo(null), + icon: Icon( + Icons.close, + size: 16, + color: Color(0xFF6A4C93), + ), + constraints: BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + padding: EdgeInsets.zero, + ), + ], + ), + ), + SizedBox(height: 12), + ], + + // Comment input + Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Color(0xFF6A4C93), + child: Text( + 'U', // TODO: Use actual user initial + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _commentController, + decoration: InputDecoration( + hintText: _replyingTo != null + ? 'Reply to ${_replyingTo!.creator.displayName}...' + : 'Write a comment...', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + hintStyle: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + maxLines: null, + textCapitalization: + TextCapitalization.sentences, + ), + ), + if (_isCreatingComment) + Container( + margin: EdgeInsets.only(right: 8), + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ) + else + IconButton( + onPressed: _createComment, + icon: Icon( + Icons.send, + color: Color(0xFF6A4C93), + size: 20, + ), + constraints: BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +}