import 'dart:math'; import 'package:comunic/enums/post_kind.dart'; import 'package:comunic/enums/post_visibility_level.dart'; import 'package:comunic/enums/report_target_type.dart'; import 'package:comunic/helpers/comments_helper.dart'; import 'package:comunic/helpers/posts_helper.dart'; import 'package:comunic/helpers/server_config_helper.dart'; import 'package:comunic/lists/groups_list.dart'; import 'package:comunic/lists/users_list.dart'; import 'package:comunic/models/comment.dart'; import 'package:comunic/models/new_comment.dart'; import 'package:comunic/models/post.dart'; import 'package:comunic/models/report_target.dart'; import 'package:comunic/models/user.dart'; import 'package:comunic/ui/dialogs/post_visibility_picker_dialog.dart'; import 'package:comunic/ui/dialogs/report_dialog.dart'; import 'package:comunic/ui/tiles/comment_tile.dart'; import 'package:comunic/ui/widgets/account_image_widget.dart'; import 'package:comunic/ui/widgets/countdown_widget.dart'; import 'package:comunic/ui/widgets/like_widget.dart'; import 'package:comunic/ui/widgets/network_image_widget.dart'; import 'package:comunic/ui/widgets/post_container_widget.dart'; import 'package:comunic/ui/widgets/survey_widget.dart'; import 'package:comunic/ui/widgets/text_widget.dart'; import 'package:comunic/utils/date_utils.dart'; import 'package:comunic/utils/files_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; import 'package:comunic/utils/navigation_utils.dart'; import 'package:comunic/utils/ui_utils.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../models/api_request.dart'; import '../../utils/log_utils.dart'; /// Single posts tile /// /// @author Pierre HUBERT /// User style const TextStyle _userNameStyle = TextStyle( color: Color.fromRGBO(0x72, 0xaf, 0xd2, 1.0), //#72afd2 fontWeight: FontWeight.w600, fontSize: 16.0); /// Post actions enum _PostActions { DELETE, UPDATE_CONTENT, REPORT } class PostTile extends StatefulWidget { final Post post; final UsersList usersInfo; final GroupsList groupsInfo; final void Function(Post) onDeletedPost; final bool showPostTarget; final bool userNamesClickable; const PostTile({ Key? key, required this.post, required this.usersInfo, required this.onDeletedPost, required this.showPostTarget, required this.groupsInfo, required this.userNamesClickable, }) : super(key: key); @override State createState() => _PostTileState(); } class _PostTileState extends State { // Helpers final _postsHelper = PostsHelper(); final _commentsHelper = CommentsHelper(); // Class members TextEditingController _commentController = TextEditingController(); BytesFile? _commentImage; bool _submitting = false; int _maxNumberOfCommentToShow = 10; User get _user => widget.usersInfo.getUser(widget.post.userID); bool get _commentValid => _commentController.text.toString().length > 3; bool get _hasImage => _commentImage != null; bool get _canSubmitComment => !_submitting && (_commentValid || _hasImage); set _sendingComment(bool sending) => setState(() => _submitting = sending); String _getPostTarget() { if (!widget.showPostTarget || (!widget.post.isGroupPost && widget.post.userID == widget.post.userPageID)) return ""; return " > " + (widget.post.isGroupPost ? widget.groupsInfo[widget.post.groupID]!.displayName : widget.usersInfo.getUser(widget.post.userPageID).displayName); } Widget _buildHeaderRow() { // Header row return Row( children: [ // User account image Padding( padding: const EdgeInsets.only(right: 8.0, left: 8.0), child: InkWell( child: AccountImageWidget(user: _user), onTap: widget.userNamesClickable ? () => openUserPage( userID: _user.id, context: context, ) : null, ), ), // Column with user name + post target Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _user.displayName + _getPostTarget(), style: _userNameStyle, ), Text(diffTimeFromNowToStr(widget.post.timeSent)!), ], ), ), InkWell( child: Icon( PostVisibilityLevelsMapIcons[widget.post.visibilityLevel], color: Colors.grey, ), onTap: widget.post.canUpdate ? updatePostVisibilityLevel : null, ), PopupMenuButton<_PostActions>( itemBuilder: (c) => [ // Update post content PopupMenuItem( child: Text(tr("Update content")!), value: _PostActions.UPDATE_CONTENT, enabled: widget.post.canUpdate, ), // Delete post PopupMenuItem( child: Text(tr("Delete")!), value: _PostActions.DELETE, enabled: widget.post.canDelete, ), ]..addAll(srvConfig!.isReportingEnabled && (!widget.post.isOwner || srvConfig!.reportPolicy!.canUserReportHisOwnContent) ? [ PopupMenuItem( child: Text(tr("Report abuse")!), value: _PostActions.REPORT, ) ] : []), onSelected: _selectedPostMenuAction, ) ], ); } Widget _buildContentRow() { Widget? postContent; switch (widget.post.kind) { case PostKind.IMAGE: postContent = _buildPostImage(); break; case PostKind.YOUTUBE: postContent = _buildPostYouTube(); break; case PostKind.WEB_LINK: postContent = _buildPostWebLink(); break; case PostKind.PDF: postContent = _buildPostPDF(); break; case PostKind.COUNTDOWN: postContent = _buildCountDownTimer(); break; case PostKind.SURVEY: postContent = _buildPostSurvey(); break; default: } return Column( children: [ // Post "rich" content Container(child: postContent), // Post text Container( child: widget.post.hasContent ? TextWidget( content: widget.post.content, parseBBcode: true, ) : null), ], ); } Widget _buildButtonsArea() { return Padding( padding: const EdgeInsets.only(top: 16.0, left: 8.0, right: 8.0, bottom: 16.0), child: Column( children: [ // Like button Center( child: LikeWidget( likeElement: widget.post, buttonIconSize: null, ), ), ], ), ); } @override Widget build(BuildContext context) { return PostContainer( child: Card( elevation: 1.0, child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ _buildHeaderRow(), _buildContentRow(), _buildButtonsArea(), ], ), ), Container( child: widget.post.hasComments ? _buildComments() : null, ), ], ), ), ); } Widget _buildPostImage() { return NetworkImageWidget( url: widget.post.fileURL!, allowFullScreen: true, roundedEdges: false, loadingHeight: 150, ); } Widget _buildPostYouTube() { return ElevatedButton( style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.ondemand_video), Text(tr("YouTube movie")!) ], ), onPressed: () => launchUrlString( "https://youtube.com/watch/?v=" + widget.post.filePath!), ); } Widget _buildPostWebLink() { return Card( color: darkTheme() ? darkerAccentColor : Color.fromRGBO(0xf7, 0xf7, 0xf7, 1), child: InkWell( onTap: () => launchUrlString(widget.post.linkURL!), child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 8.0), child: widget.post.hasLinkImage ? NetworkImageWidget( url: widget.post.linkImage!, width: 70, roundedEdges: false, ) : Icon( Icons.link, size: 70, ), ), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(htmlDecodeCharacters(widget.post.linkTitle), style: TextStyle(fontSize: 20.0)), Text( widget.post.linkURL!, maxLines: 3, ), Text(htmlDecodeCharacters(widget.post.linkDescription)) ], ), ) ], ), ), ); } Widget _buildPostPDF() { return ElevatedButton.icon( onPressed: () { launchUrlString(widget.post.fileURL!); }, icon: Icon(Icons.picture_as_pdf), label: Text(tr("PDF")!), ); } Widget _buildCountDownTimer() { return CountdownWidget( startTime: widget.post.timeSent, endTime: widget.post.timeEnd!, ); } /// Build post survey Widget _buildPostSurvey() { return SurveyWidget( survey: widget.post.survey!, onUpdated: (s) => setState(() => widget.post.survey = s), ); } /// Build the list of comments Widget _buildComments() { assert(widget.post.hasComments); final comments = List.generate( min(widget.post.comments!.length, _maxNumberOfCommentToShow), (num) { final index = num + max(0, widget.post.comments!.length - _maxNumberOfCommentToShow); final comment = widget.post.comments![index as int]; return CommentTile( comment: comment, user: widget.usersInfo.getUser(comment.userID), onUpdateComment: _updateCommentContent, onDeleteComment: _deleteComment, onReportComment: _reportComment, ); }, ); // Add comments form comments.add(_buildCommentsForm()); return Container( color: darkTheme() ? Colors.black38 : Colors.grey[300], child: Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), child: Column( children: (widget.post.comments!.length > _maxNumberOfCommentToShow ? [_buildShowMoreCommentsButton()] : []) ..addAll(comments), ), ), ); } Widget _buildShowMoreCommentsButton() => Material( color: Colors.transparent, child: InkWell( onTap: () => setState(() => _maxNumberOfCommentToShow += 10), child: Padding( padding: const EdgeInsets.all(8.0), child: Text(tr("Show more comments")!.toUpperCase()), ), ), ); /// Build comments form Widget _buildCommentsForm() { return Padding( padding: EdgeInsets.only( left: 8.0, right: 8.0, top: (widget.post.comments!.length > 0 ? 8.0 : 0)), child: Row( children: [ // Comment input Expanded( child: TextField( // Comment max size maxLength: 255, maxLines: null, buildCounter: smartInputCounterWidgetBuilder, controller: _commentController, onChanged: (s) => setState(() {}), onSubmitted: _canSubmitComment ? (s) => _submitComment() : null, style: TextStyle( color: darkTheme() ? Colors.white : null, ), decoration: InputDecoration( hintText: tr("New comment..."), hintStyle: TextStyle(color: Colors.grey, fontSize: 12), fillColor: darkTheme() ? Colors.black38 : Colors.white, filled: true, border: OutlineInputBorder( borderSide: BorderSide(width: 0.5, color: Colors.grey), borderRadius: BorderRadius.only( topLeft: Radius.circular(3), bottomLeft: Radius.circular(3), ), gapPadding: 1), contentPadding: EdgeInsets.only( left: 10, right: 10, bottom: 5, top: 5, ), ), ), ), // Image button Container( width: 30, child: TextButton( onPressed: _pickImageForComment, child: Icon( Icons.image, color: _hasImage ? Colors.blue : Colors.grey, ), ), ), // Submit button Container( width: 40, child: TextButton( onPressed: _canSubmitComment ? () => _submitComment() : null, child: Icon( Icons.send, color: _canSubmitComment ? Colors.blue : Colors.grey, ), ), ), ], ), ); } /// Clear comments submitting form void clearCommentForm() { setState(() { _commentController.text = ""; _commentImage = null; }); } /// Pick an image Future _pickImageForComment() async { // Ask the user to confirm image removal if there is already one selected if (_hasImage) { if (await showConfirmDialog( context: context, title: tr("Remove selected image"), message: tr("Do you want to unselected currently selected image ?"), )) { setState(() { _commentImage = null; }); } return; } try { // Pick a new image final newImage = await pickImage(context); setState(() { _commentImage = newImage; }); } catch (e, s) { logError(e, s); snack(context, tr("Failed to choose an image!")!); } } /// Submit comment entered by the user Future _submitComment() async { try { _sendingComment = true; final commentID = await _commentsHelper.createComment(NewComment( postID: widget.post.id, content: _commentController.text, image: _commentImage, )); _sendingComment = false; if (commentID < 1) throw new Exception("Comment ID is inferior to 1!"); clearCommentForm(); } catch (e) { print(e); showSimpleSnack(context, tr("Could not create comment!")!); } } /// Update comment content Future _updateCommentContent(Comment comment) async { final newContent = await askUserString( context: context, title: tr("Update comment content")!, message: tr("New content:")!, defaultValue: comment.content.isNull ? "" : comment.content.content!, hint: tr("New content...")!, ); if (!(await _commentsHelper.updateContent(comment.id, newContent))) return showSimpleSnack(context, tr("Could not update comment content!")!); } /// Process the deletion of a user Future _deleteComment(Comment comment) async { if (!await showConfirmDialog( context: context, message: tr("Do you really want to delete this comment ?"), title: tr("Delete comment"))) return; if (!await _commentsHelper.delete(comment.id)) { showSimpleSnack(context, tr("Could not delete the comment!")!); return; } } /// Process a report request void _reportComment(Comment comment) => showReportDialog( ctx: context, target: ReportTarget(ReportTargetType.Comment, comment.id)); /// Method called each time the user has selected an option void _selectedPostMenuAction(_PostActions value) { switch (value) { // Update post content case _PostActions.UPDATE_CONTENT: updateContent(); break; // Delete post case _PostActions.DELETE: confirmDelete(); break; // Report content case _PostActions.REPORT: reportContent(); break; } } /// Update post visibility level Future updatePostVisibilityLevel() async { final newLevel = await showPostVisibilityPickerDialog( context: context, initialLevel: widget.post.visibilityLevel, isGroup: widget.post.isGroupPost, ); if (newLevel == widget.post.visibilityLevel) return; // Update post visibility if (!await _postsHelper.setVisibility(widget.post.id, newLevel)) { showSimpleSnack(context, tr("Could not update post visibility!")!); return; } setState(() => widget.post.visibilityLevel = newLevel); } /// Update post content Future updateContent() async { final newContent = await askUserString( context: context, title: tr("Update post content")!, message: tr("Please enter message content: ")!, defaultValue: widget.post.content.isNull ? "" : widget.post.content.content!, hint: tr("Post content")!, ); if (newContent == null) return; if (!await _postsHelper.updateContent(widget.post.id, newContent)) { showSimpleSnack(context, tr("Could not update post content!")!); return; } setState(() => widget.post.content.content = newContent); } /// Perform the deletion of the post Future confirmDelete() async { // Ask user confirmation if (!await showConfirmDialog( context: context, message: tr( "Do you really want to delete this post ? The operation can not be reverted !"), )) return; if (!await _postsHelper.delete(widget.post.id)) { showSimpleSnack(context, tr("Could not delete the post!")!); return; } widget.onDeletedPost(widget.post); } /// Report post void reportContent() async => await showReportDialog( ctx: context, target: ReportTarget(ReportTargetType.Post, widget.post.id)); }