diff --git a/app/lib/service/http_service.dart b/app/lib/service/http_service.dart index 9280084..6806d13 100644 --- a/app/lib/service/http_service.dart +++ b/app/lib/service/http_service.dart @@ -21,8 +21,10 @@ import 'package:opengov_common/models/poll.dart'; import 'package:opengov_common/models/report.dart'; import 'package:opengov_common/models/token.dart'; import 'package:opengov_common/models/user.dart'; +import 'package:opengov_common/actions/comment_details.dart'; import 'package:shared_preferences/shared_preferences.dart'; + class HttpService { static final _client = Client(); @@ -30,13 +32,14 @@ class HttpService { var scheme = 'https'; var host = 'app.crimsonopengov.us'; int? port; - + assert(() { scheme = 'http'; host = '192.168.2.198'; port = 8017; return true; }()); + return Uri(scheme: scheme, host: host, port: port, path: 'api/$path'); } @@ -83,12 +86,15 @@ class HttpService { static Future getCommentDetails(CommentBase comment) => _get('poll/comment/${comment.id}', Comment.fromJson); - static Future getReport(Poll poll) => - _get('poll/report/${poll.id}', Report.fromJson); + static Future getReport(int pollId, int parentId) => + _get('poll/report/$pollId/$parentId', Report.fromJson); static Future addComment(AddCommentRequest request) => _post('poll/add-comment', request.toJson(), AddCommentResponse.fromJson); + static Future getCommentReplies(int commentId) => + _get('poll/details/comment/$commentId', CommentDetailsResponse.fromJson); + static Future vote(VoteRequest request) => _post('poll/vote', request.toJson(), GenericResponse.fromJson); @@ -123,6 +129,6 @@ class HttpService { static Future getRandomFeed() => _get('feed/random', FeedResponse.fromJson); - + static Future getMe() => _get('user/me', User.fromJson); } diff --git a/app/lib/service/notification_service.dart b/app/lib/service/notification_service.dart index cbbdac3..9cfd6e0 100644 --- a/app/lib/service/notification_service.dart +++ b/app/lib/service/notification_service.dart @@ -21,7 +21,7 @@ class NotificationService { context, MaterialPageRoute( builder: (_) => - PollDetails(pollId: int.parse(message.data['pollId']))), + PollDetails(parentId: int.parse(message.data['pollId']), isReply: false)), ); } else if (message.data.containsKey('announcementId')) { Navigator.push( diff --git a/app/lib/widgets/announcement/announcement_details.dart b/app/lib/widgets/announcement/announcement_details.dart index 613c4b7..21c8064 100644 --- a/app/lib/widgets/announcement/announcement_details.dart +++ b/app/lib/widgets/announcement/announcement_details.dart @@ -70,7 +70,7 @@ class _AnnouncementDetailsState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => PollDetails(pollId: _poll!.id)), + builder: (_) => PollDetails(parentId: _poll!.id, isReply: false)), ); }, ), diff --git a/app/lib/widgets/feed/feed_view.dart b/app/lib/widgets/feed/feed_view.dart index 2a19e96..e5ee4a9 100644 --- a/app/lib/widgets/feed/feed_view.dart +++ b/app/lib/widgets/feed/feed_view.dart @@ -63,9 +63,10 @@ class _FeedViewState extends State { CommentList( comments: _comments!, onActionPressed: _fetchComment, + isReply: false, ), OutlinedButton( - child: const Text('Load more comments'), + child: const Text('Load more messages'), onPressed: _fetchData, ), ], diff --git a/app/lib/widgets/polls/details/add_comment.dart b/app/lib/widgets/polls/details/add_comment.dart index 4f2bef7..be70580 100644 --- a/app/lib/widgets/polls/details/add_comment.dart +++ b/app/lib/widgets/polls/details/add_comment.dart @@ -5,9 +5,9 @@ import 'package:opengov_common/actions/add_comment.dart'; import 'package:opengov_common/models/poll.dart'; class AddComment extends StatefulWidget { - final Poll poll; - - const AddComment({required this.poll}); + final int pollId; + final int parentId; //0 if top level comment + const AddComment({required this.pollId, required this.parentId}); @override _AddCommentState createState() => _AddCommentState(); @@ -32,22 +32,23 @@ class _AddCommentState extends State { Future _addComment() async { final response = await HttpService.addComment(AddCommentRequest( - pollId: widget.poll.id, - comment: _textController.text, - )); + pollId: widget.pollId, + comment: _textController.text, + parentId: widget.parentId, + )); switch (response?.reason) { case null: case AddCommentResponseReason.error: setState(() { _responseMessage = - 'A server error occurred while posting your comment.'; + 'A server error occurred while posting your message.'; }); break; case AddCommentResponseReason.curseWords: setState(() { _responseMessage = - "Please ensure that your comment doesn't contain any swear " + "Please ensure that your message doesn't contain any swear " "words."; }); break; @@ -56,9 +57,9 @@ class _AddCommentState extends State { setState(() { _responseMessage = response!.reason == AddCommentResponseReason.needsApproval - ? 'Comment sent! Your comment will be displayed to other users ' + ? 'Message sent! Your message will be displayed to other users ' 'once it is approved by an admin.' - : 'Comment sent! Your comment is now visible to other users.'; + : 'Message sent! Your message is now visible to other users.'; _textController.clear(); }); if (await InAppReview.instance.isAvailable()) { @@ -86,7 +87,7 @@ class _AddCommentState extends State { children: [ const Expanded( child: Text( - 'Your comment will appear in other people’s feeds to be ' + 'Your message will appear in other people’s feeds to be ' 'voted on.', style: TextStyle(fontStyle: FontStyle.italic), ), diff --git a/app/lib/widgets/polls/details/comment_card.dart b/app/lib/widgets/polls/details/comment_card.dart index 93b4973..48d0578 100644 --- a/app/lib/widgets/polls/details/comment_card.dart +++ b/app/lib/widgets/polls/details/comment_card.dart @@ -23,8 +23,9 @@ extension CommentActionScore on CommentAction { class CommentCard extends StatefulWidget { final CommentBase comment; final VoidCallback onActionPressed; - - const CommentCard({required this.comment, required this.onActionPressed}); + final isReply; + + const CommentCard({required this.comment, required this.onActionPressed, required this.isReply}); @override State createState() => _CommentCardState(); @@ -32,7 +33,7 @@ class CommentCard extends StatefulWidget { class _CommentCardState extends State { static const _showReason = false; - + final _reasonController = TextEditingController(); void _onHelpPressed() { @@ -40,11 +41,11 @@ class _CommentCardState extends State { context, title: 'Share a reason', body: 'You can optionally include a reason along with your vote. Your ' - 'reason will only be visible to Crimson OpenGov admins, who will ' - 'share the reasons with Harvard administrators making decisions.\n\n' - 'However, just like your votes and responses, all reasons are ' - 'anonymous–no personally identifying info is shared with your ' - 'responses.', + 'reason will only be visible to Crimson OpenGov admins, who will ' + 'share the reasons with Harvard administrators making decisions.\n\n' + 'However, just like your votes and responses, all reasons are ' + 'anonymous–no personally identifying info is shared with your ' + 'responses.', ); } @@ -58,139 +59,204 @@ class _CommentCardState extends State { } } + Future _onReplyPressed() async { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PollDetails(parentId : widget.comment.id, isReply : true))); + } + + + @override Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.comment is FeedComment) ...[ - ListTile( - leading: Text( - (widget.comment as FeedComment).pollEmoji, - style: const TextStyle(fontSize: 24), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.comment is FeedComment) ...[ + ListTile( + leading: Text( + (widget.comment as FeedComment).pollEmoji, + style: const TextStyle(fontSize: 24), + ), + title: Text((widget.comment as FeedComment).pollTopic), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PollDetails( + parentId: (widget.comment as FeedComment).pollId, + isReply: false), ), - title: Text((widget.comment as FeedComment).pollTopic), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PollDetails( - pollId: (widget.comment as FeedComment).pollId)), - ); - }, + ); + }, + ), + const Divider(), + ], + LinkedText( + widget.comment.comment, + fontSize: 16, + ), + if (_showReason) ...[ + const SizedBox(height: 8), + TextField( + controller: _reasonController, + decoration: InputDecoration( + hintText: "Share a reason (anonymous; optional)", + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.help), + onPressed: _onHelpPressed, ), - const Divider(), - ], - LinkedText( - widget.comment.comment, - fontSize: 16, ), - if (_showReason) ...[ - const SizedBox(height: 8), - TextField( - controller: _reasonController, - decoration: InputDecoration( - hintText: "Share a reason (anonymous; optional)", - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.help), - onPressed: _onHelpPressed, - ), + maxLines: 3, + textCapitalization: TextCapitalization.sentences, + ), + ], + const SizedBox(height: 8), + widget.comment.stats == null + ? SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceAround, + children: [ + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.green), + side: MaterialStateProperty.all(BorderSide.none), + ), + onPressed: () { + _onVotePressed(CommentAction.agree); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.check_circle_outline, + color: Colors.white), + SizedBox(width: 8), + Text( + 'Agree', + style: TextStyle(color: Colors.white), + ), + ], ), - maxLines: 3, - textCapitalization: TextCapitalization.sentences, ), - ], - const SizedBox(height: 8), - widget.comment.stats == null - ? SizedBox( - width: double.infinity, - child: Wrap( - alignment: WrapAlignment.spaceAround, - children: [ - TextButton( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Colors.green), - side: MaterialStateProperty.all(BorderSide.none), - ), - onPressed: () { - _onVotePressed(CommentAction.agree); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.check_circle_outline, - color: Colors.white), - SizedBox(width: 8), - Text( - 'Agree', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - TextButton( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Colors.red), - side: MaterialStateProperty.all(BorderSide.none), - ), - onPressed: () { - _onVotePressed(CommentAction.disagree); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.block, color: Colors.white), - SizedBox(width: 8), - Text( - 'Disagree', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - TextButton( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Colors.grey), - side: MaterialStateProperty.all(BorderSide.none), - ), - onPressed: () { - _onVotePressed(CommentAction.pass); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.redo, color: Colors.white), - SizedBox(width: 8), - Text( - 'Unsure/Neutral', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - ], + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.red), + side: MaterialStateProperty.all(BorderSide.none), + ), + onPressed: () { + _onVotePressed(CommentAction.disagree); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.block, color: Colors.white), + SizedBox(width: 8), + Text( + 'Disagree', + style: TextStyle(color: Colors.white), ), - ) - : Center( - child: Neapolitan( - pieces: [ - widget.comment.stats!.agreeCount, - widget.comment.stats!.passCount, - widget.comment.stats!.disagreeCount - ], - colors: const [Colors.green, Colors.white, Colors.red], + ], + ), + ), + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.grey), + side: MaterialStateProperty.all(BorderSide.none), + ), + onPressed: () { + _onVotePressed(CommentAction.pass); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.redo, color: Colors.white), + SizedBox(width: 8), + Text( + 'Unsure/Neutral', + style: TextStyle(color: Colors.white), ), + ], + ), + ), + if (!widget.isReply) ...[ + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.blue), + side: MaterialStateProperty.all(BorderSide.none), + ), + onPressed: () { + _onReplyPressed(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + //to change the icon for reply: + Icon(Icons.redo, color: Colors.white), + SizedBox(width: 8), + Text( + 'View Replies', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ], + ), + ) + : SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceAround, + children: [ + Neapolitan( + pieces: [ + widget.comment.stats!.agreeCount, + widget.comment.stats!.passCount, + widget.comment.stats!.disagreeCount + ], + colors: const [Colors.green, Colors.white, Colors.red], + ), + if (!widget.isReply) ...[ + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.blue), + side: MaterialStateProperty.all(BorderSide.none), ), - ], - ); + onPressed: () { + _onReplyPressed(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + //to change the icon for reply: + Icon(Icons.redo, color: Colors.white), + SizedBox(width: 8), + Text( + 'View Replies', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ); - @override - void dispose() { - _reasonController.dispose(); - super.dispose(); + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } } -} diff --git a/app/lib/widgets/polls/details/comment_list.dart b/app/lib/widgets/polls/details/comment_list.dart index afe0835..f2526e3 100644 --- a/app/lib/widgets/polls/details/comment_list.dart +++ b/app/lib/widgets/polls/details/comment_list.dart @@ -5,8 +5,9 @@ import 'package:opengov_common/models/comment.dart'; class CommentList extends StatefulWidget { final List comments; final void Function(int i) onActionPressed; - - const CommentList({required this.comments, required this.onActionPressed}); + final isReply; + + const CommentList({required this.comments, required this.onActionPressed, required this.isReply}); @override State createState() => _CommentListState(); @@ -20,7 +21,7 @@ class _CommentListState extends State { @override Widget build(BuildContext context) => widget.comments.isEmpty - ? const Text('No more comments.') + ? const Text('No more messages.') : Column( children: [ for (var i = 0; i < widget.comments.length; i++) @@ -39,6 +40,7 @@ class _CommentListState extends State { onActionPressed: () { widget.onActionPressed(i); }, + isReply:widget.isReply, ), ), ), diff --git a/app/lib/widgets/polls/details/poll_details.dart b/app/lib/widgets/polls/details/poll_details.dart index 36896fa..bd351cd 100644 --- a/app/lib/widgets/polls/details/poll_details.dart +++ b/app/lib/widgets/polls/details/poll_details.dart @@ -7,31 +7,36 @@ import 'package:opengov_common/models/comment.dart'; import 'package:opengov_common/models/poll.dart'; class PollDetails extends StatefulWidget { - final int pollId; - - const PollDetails({required this.pollId}); - + final int parentId; //parent is a poll if not reply, and comment otherwise + final isReply; + const PollDetails({required this.parentId, required this.isReply}); + @override _PollDetailsState createState() => _PollDetailsState(); } class _PollDetailsState extends State { - Poll? _poll; + Map? _parentText; List? _comments; - + @override - void initState() { + void initState() { super.initState(); _fetchComments(); } Future _fetchComments() async { - final response = await HttpService.getPollDetails(widget.pollId); - + final response; + if (!widget.isReply) { + response = await HttpService.getPollDetails(widget.parentId); + } else { + response = await HttpService.getCommentReplies(widget.parentId); + } + if (response != null) { setState(() { - _poll = response.poll; - _comments = response.comments; + _parentText = response.parent.details; + _comments = response.messages; }); } } @@ -41,43 +46,44 @@ class _PollDetailsState extends State { if (response != null) { setState(() { - _comments![i] = response; + _comments![i] = response; }); } } @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Poll')), - body: _poll == null || _comments == null - ? const Center(child: CircularProgressIndicator()) - : Padding( - padding: const EdgeInsets.all(8), - child: ListView( - children: [ - Text( - _poll!.topic, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - if (_poll!.description != null) ...[ - LinkedText( - _poll!.description!, - fontSize: 18, - ), - const SizedBox(height: 16), - ], - AddComment(poll: _poll!), - const SizedBox(height: 16), - CommentList( - comments: _comments!, - onActionPressed: _updateComment, - ), - ], - ), - ), - ); + appBar: AppBar(title: const Text('Poll Details')), + body: _parentText == null || _comments == null + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(8), + child: ListView( + children: [ + Text( + _parentText!['topic'], + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_parentText!['description'] != null) ...[ + LinkedText( + _parentText!['description']!, + fontSize: 18, + ), + const SizedBox(height: 16), + ], + AddComment(pollId: _parentText!['pollId'], parentId: _parentText!['commentId']), + const SizedBox(height: 16), + CommentList( + comments: _comments!, + onActionPressed: _updateComment, + isReply: widget.isReply, + ), + ], + ), + ), + ); } diff --git a/app/lib/widgets/polls/poll_admin.dart b/app/lib/widgets/polls/poll_admin.dart index 6ad6276..bc452b5 100644 --- a/app/lib/widgets/polls/poll_admin.dart +++ b/app/lib/widgets/polls/poll_admin.dart @@ -12,8 +12,9 @@ import 'package:opengov_common/models/poll.dart'; class PollAdmin extends StatefulWidget { final Poll poll; - - const PollAdmin({required this.poll}); + final Comment? comment; + final isReply; + const PollAdmin({required this.poll, required this.comment, required this.isReply}); @override _PollAdminState createState() => _PollAdminState(); @@ -21,13 +22,16 @@ class PollAdmin extends StatefulWidget { class _PollAdminState extends State { late Poll _poll; + late Comment? _comment; Iterable? _moderationQueue; Iterable? _approvedComments; - + Map? _parentText; + @override void initState() { super.initState(); _poll = widget.poll; + _comment = widget.comment; _fetchComments(); } @@ -35,21 +39,35 @@ class _PollAdminState extends State { void didUpdateWidget(covariant PollAdmin oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.poll != oldWidget.poll) { + if (widget.poll != oldWidget.poll || widget.comment != oldWidget.comment) { _poll = widget.poll; + _comment = widget.comment; _fetchComments(); } } Future _fetchComments() async { - final response = await HttpService.getReport(_poll); - + final response; + if (!widget.isReply) { + response = await HttpService.getReport(_poll.id,0); + setState(() {_parentText = _poll.details;}); + } else { + response = await HttpService.getReport(_poll.id, _comment!.id); + setState(() {_parentText = _comment!.details;}); + } + if (response != null) { setState(() { - _moderationQueue = - response.comments.where((comment) => !comment.isApproved); - _approvedComments = - response.comments.where((comment) => comment.isApproved); + _moderationQueue = + response.comments.where((comment) => !comment.isApproved); + _approvedComments = + response.comments.where((comment) => !!comment.isApproved); //?????? + /* The above line does not seem to work without the '!!' + If I replace lines 52-57 above with: + 'final response = await HttpService.getReport(_poll.id,0);' + then I don't need the !!, however when I use the if (!widget.isReply)... + above, then I do need the !!. I have no clue what is going on here. + */ }); } } @@ -64,6 +82,14 @@ class _PollAdminState extends State { } } + Future _onModeratePressed(Comment comment) async { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PollAdmin(poll: _poll, comment: comment, isReply: true))); + } + + Future _deletePoll() async { final shouldDelete = await showConfirmationDialog(context, body: 'Are you sure you want to delete this poll?'); @@ -131,6 +157,31 @@ class _PollAdminState extends State { ), tooltip: 'Approve comment', ), + if (!widget.isReply) ...[ + const SizedBox(width: 16), + TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Colors.blue), + side: MaterialStateProperty.all(BorderSide.none), + ), + onPressed: () { + _onModeratePressed(comment); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + //to change the icon for reply: + Icon(Icons.redo, color: Colors.white), + SizedBox(width: 8), + Text( + 'Moderate Replies', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], ], ), ); @@ -160,16 +211,16 @@ class _PollAdminState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _poll.topic, + _parentText!['topic'], style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), - if (_poll.description != null) ...[ + if (_parentText!['description'] != null) ...[ LinkedText( - _poll.description!, + _parentText!['description']!, fontSize: 18, ), const SizedBox(height: 16), diff --git a/app/lib/widgets/polls/poll_report.dart b/app/lib/widgets/polls/poll_report.dart index 177affc..4809fd1 100644 --- a/app/lib/widgets/polls/poll_report.dart +++ b/app/lib/widgets/polls/poll_report.dart @@ -11,7 +11,6 @@ import 'package:opengov_common/models/report.dart'; class PollReport extends StatefulWidget { final Poll poll; - const PollReport({required this.poll}); @override @@ -28,7 +27,7 @@ class _PollReportState extends State { } Future _fetchReport() async { - final response = await HttpService.getReport(widget.poll); + final response = await HttpService.getReport(widget.poll.id,0); if (response != null) { setState(() { diff --git a/app/lib/widgets/polls/polls_list.dart b/app/lib/widgets/polls/polls_list.dart index fef8ece..e991358 100644 --- a/app/lib/widgets/polls/polls_list.dart +++ b/app/lib/widgets/polls/polls_list.dart @@ -82,9 +82,9 @@ class _PollsListState extends State { context, MaterialPageRoute( builder: (_) => poll.isActive - ? isAdmin - ? PollAdmin(poll: poll) - : PollDetails(pollId: poll.id) + ? isAdmin + ? PollAdmin(poll: poll, comment: null, isReply: false) + : PollDetails(parentId: poll.id, isReply: false) : PollReport(poll: poll), ), ); diff --git a/common/lib/actions/add_comment.dart b/common/lib/actions/add_comment.dart index 8c52298..35ff5f5 100644 --- a/common/lib/actions/add_comment.dart +++ b/common/lib/actions/add_comment.dart @@ -7,8 +7,9 @@ part 'add_comment.g.dart'; class AddCommentRequest { final int pollId; final String comment; - - const AddCommentRequest({required this.pollId, required this.comment}); + final int? parentId; //null if top-level comment + + const AddCommentRequest({required this.pollId, required this.comment, required this.parentId}); factory AddCommentRequest.fromJson(Json json) => _$AddCommentRequestFromJson(json); diff --git a/common/lib/actions/add_comment.g.dart b/common/lib/actions/add_comment.g.dart index 53a9e89..59cbade 100644 --- a/common/lib/actions/add_comment.g.dart +++ b/common/lib/actions/add_comment.g.dart @@ -10,12 +10,14 @@ AddCommentRequest _$AddCommentRequestFromJson(Map json) => AddCommentRequest( pollId: json['pollId'] as int, comment: json['comment'] as String, + parentId: json['parentId'] as int?, ); Map _$AddCommentRequestToJson(AddCommentRequest instance) => { 'pollId': instance.pollId, 'comment': instance.comment, + 'parentId': instance.parentId, }; AddCommentResponse _$AddCommentResponseFromJson(Map json) => diff --git a/common/lib/actions/comment_details.dart b/common/lib/actions/comment_details.dart new file mode 100644 index 0000000..a993eca --- /dev/null +++ b/common/lib/actions/comment_details.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:opengov_common/common.dart'; +import 'package:opengov_common/models/comment.dart'; + +part 'comment_details.g.dart'; + +@JsonSerializable() +class CommentDetailsResponse { + final Comment parent; + final List messages; + + CommentDetailsResponse({required this.parent, required this.messages}); + + factory CommentDetailsResponse.fromJson(Json json) => + _$CommentDetailsResponseFromJson(json); + + Json toJson() => _$CommentDetailsResponseToJson(this); +} diff --git a/common/lib/actions/comment_details.g.dart b/common/lib/actions/comment_details.g.dart new file mode 100644 index 0000000..9971601 --- /dev/null +++ b/common/lib/actions/comment_details.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'comment_details.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CommentDetailsResponse _$CommentDetailsResponseFromJson( + Map json) => + CommentDetailsResponse( + parent: Comment.fromJson(json['parent'] as Map), + messages: (json['messages'] as List) + .map((e) => Comment.fromJson(e as Map)) + .toList(), + ); + +Map _$CommentDetailsResponseToJson( + CommentDetailsResponse instance) => + { + 'parent': instance.parent, + 'messages': instance.messages, + }; diff --git a/common/lib/actions/delete_comment.dart b/common/lib/actions/delete_comment.dart new file mode 100644 index 0000000..37eb048 --- /dev/null +++ b/common/lib/actions/delete_comment.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:opengov_common/common.dart'; + +part 'delete_comment.g.dart'; + +@JsonSerializable() +class DeleteCommentRequest { + final int commentId; + + const DeleteCommentRequest({required this.commentId}); + + factory DeleteCommentRequest.fromJson(Json json) => + _$DeleteCommentRequestFromJson(json); + + Json toJson() => _$DeleteCommentRequestToJson(this); +} diff --git a/common/lib/actions/delete_comment.g.dart b/common/lib/actions/delete_comment.g.dart new file mode 100644 index 0000000..3e2490c --- /dev/null +++ b/common/lib/actions/delete_comment.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delete_comment.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DeleteCommentRequest _$DeleteCommentRequestFromJson( + Map json) => + DeleteCommentRequest( + commentId: json['commentId'] as int, + ); + +Map _$DeleteCommentRequestToJson( + DeleteCommentRequest instance) => + { + 'commentId': instance.commentId, + }; diff --git a/common/lib/actions/poll_details.dart b/common/lib/actions/poll_details.dart index 1994340..cde7147 100644 --- a/common/lib/actions/poll_details.dart +++ b/common/lib/actions/poll_details.dart @@ -7,10 +7,10 @@ part 'poll_details.g.dart'; @JsonSerializable() class PollDetailsResponse { - final Poll poll; - final List comments; + final Poll parent; + final List messages; - PollDetailsResponse({required this.poll, required this.comments}); + PollDetailsResponse({required this.parent, required this.messages}); factory PollDetailsResponse.fromJson(Json json) => _$PollDetailsResponseFromJson(json); diff --git a/common/lib/actions/poll_details.g.dart b/common/lib/actions/poll_details.g.dart index 867a0fd..02b963a 100644 --- a/common/lib/actions/poll_details.g.dart +++ b/common/lib/actions/poll_details.g.dart @@ -8,8 +8,8 @@ part of 'poll_details.dart'; PollDetailsResponse _$PollDetailsResponseFromJson(Map json) => PollDetailsResponse( - poll: Poll.fromJson(json['poll'] as Map), - comments: (json['comments'] as List) + parent: Poll.fromJson(json['parent'] as Map), + messages: (json['messages'] as List) .map((e) => Comment.fromJson(e as Map)) .toList(), ); @@ -17,6 +17,6 @@ PollDetailsResponse _$PollDetailsResponseFromJson(Map json) => Map _$PollDetailsResponseToJson( PollDetailsResponse instance) => { - 'poll': instance.poll, - 'comments': instance.comments, + 'parent': instance.parent, + 'messages': instance.messages, }; diff --git a/common/lib/models/comment.dart b/common/lib/models/comment.dart index a8b0ecc..bdb1eeb 100644 --- a/common/lib/models/comment.dart +++ b/common/lib/models/comment.dart @@ -21,6 +21,9 @@ class Comment extends CommentBase { @JsonKey(name: 'poll_id') final int pollId; + @JsonKey(name: 'parent_id') + final int? parentId; + @JsonKey(name: 'user_id') final int? userId; @@ -39,6 +42,7 @@ class Comment extends CommentBase { const Comment({ required this.id, required this.pollId, + required this.parentId, required this.userId, required this.comment, required this.timestamp, @@ -48,9 +52,18 @@ class Comment extends CommentBase { factory Comment.fromJson(Json json) => _$CommentFromJson(json); + Map get details => + {'commentId': id, + 'pollId':pollId, + 'topic': comment, + 'description':timestamp.toString() + }; + + Comment copyWith({required CommentStats stats}) => Comment( id: id, pollId: pollId, + parentId: parentId, userId: userId, comment: comment, timestamp: timestamp, diff --git a/common/lib/models/comment.g.dart b/common/lib/models/comment.g.dart index be818e8..fe0b886 100644 --- a/common/lib/models/comment.g.dart +++ b/common/lib/models/comment.g.dart @@ -9,6 +9,7 @@ part of 'comment.dart'; Comment _$CommentFromJson(Map json) => Comment( id: json['id'] as int, pollId: json['poll_id'] as int, + parentId: json['parent_id'] as int?, userId: json['user_id'] as int?, comment: json['comment'] as String, timestamp: dateTimeFromJson(json['timestamp'] as int), @@ -23,6 +24,7 @@ Comment _$CommentFromJson(Map json) => Comment( Map _$CommentToJson(Comment instance) => { 'id': instance.id, 'poll_id': instance.pollId, + 'parent_id': instance.parentId, 'user_id': instance.userId, 'comment': instance.comment, 'timestamp': dateTimeToJson(instance.timestamp), diff --git a/common/lib/models/poll.dart b/common/lib/models/poll.dart index 94ca530..1f4d416 100644 --- a/common/lib/models/poll.dart +++ b/common/lib/models/poll.dart @@ -21,12 +21,12 @@ class Poll { final bool isPermanent; const Poll({ - required this.id, - required this.topic, - required this.description, - required this.end, - required this.emoji, - this.isPermanent = false, + required this.id, + required this.topic, + required this.description, + required this.end, + required this.emoji, + this.isPermanent = false, }); factory Poll.fromJson(Json json) => _$PollFromJson(json); @@ -36,32 +36,39 @@ class Poll { bool get isActive => end.isAfter(DateTime.now()); String get endFormatted => prettyDuration( - end.difference(DateTime.now()), - tersity: DurationTersity.minute, - spacer: '', - conjunction: ', and ', - abbreviated: true, - ); + end.difference(DateTime.now()), + tersity: DurationTersity.minute, + spacer: '', + conjunction: ', and ', + abbreviated: true, + ); + Map get details => + {'commentId': 0, + 'pollId':id, + 'topic': topic, + 'description':description + }; + Poll copyWith({int? id}) => Poll( - id: id ?? this.id, - topic: topic, - description: description, - end: end, - emoji: emoji, - isPermanent: isPermanent); + id: id ?? this.id, + topic: topic, + description: description, + end: end, + emoji: emoji, + isPermanent: isPermanent); @override bool operator ==(Object other) => - other is Poll && - other.id == id && - other.topic == topic && - other.description == description && - other.end == end && - other.emoji == emoji && - other.isPermanent == isPermanent; + other is Poll && + other.id == id && + other.topic == topic && + other.description == description && + other.end == end && + other.emoji == emoji && + other.isPermanent == isPermanent; @override int get hashCode => - Object.hash(id, topic, description, end, emoji, isPermanent); + Object.hash(id, topic, description, end, emoji, isPermanent); } diff --git a/common/lib/models/report.dart b/common/lib/models/report.dart index 83819bf..aaabdd9 100644 --- a/common/lib/models/report.dart +++ b/common/lib/models/report.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:opengov_common/common.dart'; import 'package:opengov_common/models/comment.dart'; +import 'package:opengov_common/models/poll.dart'; part 'report.g.dart'; diff --git a/common/pubspec.lock b/common/pubspec.lock index 4288b09..5acd46f 100644 --- a/common/pubspec.lock +++ b/common/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "34.0.0" + version: "32.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "3.0.0" args: dependency: transitive description: diff --git a/common/pubspec.yaml b/common/pubspec.yaml index c478d4d..6b0b79c 100644 --- a/common/pubspec.yaml +++ b/common/pubspec.yaml @@ -15,4 +15,4 @@ dependencies: dev_dependencies: build_runner: ^2.1.7 json_serializable: ^6.1.4 - lints: ^1.0.1 + lints: ^1.0.1 \ No newline at end of file diff --git a/server/bin/server.dart b/server/bin/server.dart index a39644b..f6b0f0c 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -13,8 +13,7 @@ import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; void main(List args) async { - final connection = PostgreSQLConnection("localhost", 5432, "opengov", - username: dbUsername, password: dbPassword); + final connection = PostgreSQLConnection("localhost", 5432, "opengov",username: dbUsername, password: dbPassword); await connection.open(); await CurseWords.setup(); @@ -33,10 +32,12 @@ void main(List args) async { var host = '127.0.0.1'; + assert(() { host = '192.168.2.198'; return true; }()); + final server = await serve(handler, host, 8017); diff --git a/server/lib/database.dart b/server/lib/database.dart index 56c8075..ebed691 100644 --- a/server/lib/database.dart +++ b/server/lib/database.dart @@ -21,7 +21,7 @@ extension PostgresExtension on PostgreSQLExecutionContext { var query = [ 'SELECT * FROM "$table"', - if (where != null) ...['WHERE', _listMap(where, 'Where')], + if (where != null) ...['WHERE', _listMap(where, 'Where', separator: ' AND ')], if (orderBy != null) 'ORDER BY $orderBy', ]; diff --git a/server/lib/services/admin_service.dart b/server/lib/services/admin_service.dart index 666784a..58d30e0 100644 --- a/server/lib/services/admin_service.dart +++ b/server/lib/services/admin_service.dart @@ -70,6 +70,24 @@ class AdminService { return genericResponse(success: dbResponse != 0); } + /*@Route.post('/delete-comment') + Future deleteComment(Request request) async { + final user = await request.decodeAuth(_connection); + + if (user?.isNotAdmin ?? true) { + return Response.forbidden(null); + } + + final deleteCommentRequest = + await request.readAsObject(DeleteCommentRequest.fromJson); + + final dbResponse = await _connection + .delete('comment', where: {'id': deleteCommentRequest.commentId}); + + return genericResponse(success: dbResponse != 0); + }*/ + + @Route.post('/update-comment') Future updateComment(Request request) async { final user = await request.decodeAuth(_connection); diff --git a/server/lib/services/auth_service.dart b/server/lib/services/auth_service.dart index 946c835..2d675ed 100644 --- a/server/lib/services/auth_service.dart +++ b/server/lib/services/auth_service.dart @@ -28,7 +28,7 @@ class AuthService { .toLowerCase() .replaceAll(badUsernameCharacters, ''); - if (username == 'appletest') { + if (username == 'appletest' || username == 'appleadmin') { return genericResponse(success: true); } @@ -60,7 +60,7 @@ class AuthService { final code = verificationRequest.code; final token = Token.generate(username, secretKey); - if (username != 'appletest' || code != '1234') { + if ((username != 'appletest' || code != '1234') && (username!='appleadmin'||code!='1234')) { final pendingLogins = (await _connection .select('pending_login', where: {'token': token.value})) .map(PendingLogin.fromJson) diff --git a/server/lib/services/feed_service.dart b/server/lib/services/feed_service.dart index 307246f..18535f5 100644 --- a/server/lib/services/feed_service.dart +++ b/server/lib/services/feed_service.dart @@ -13,6 +13,7 @@ class FeedService { const FeedService(this._connection); + //if don't want replies in feed, then need to get rows from comment where parent_id is null static const _randomFeedQuery = 'select c.id, p.id as poll_id, c.comment, p.topic as poll_topic, ' 'p.emoji as poll_emoji ' diff --git a/server/lib/services/poll_service.dart b/server/lib/services/poll_service.dart index 6e7d0dd..5351c88 100644 --- a/server/lib/services/poll_service.dart +++ b/server/lib/services/poll_service.dart @@ -4,6 +4,7 @@ import 'package:opengov_common/actions/add_comment.dart'; import 'package:opengov_common/actions/list_polls.dart'; import 'package:opengov_common/actions/poll_details.dart'; import 'package:opengov_common/actions/vote.dart'; +import 'package:opengov_common/actions/comment_details.dart'; import 'package:opengov_common/models/comment.dart'; import 'package:opengov_common/models/poll.dart'; import 'package:opengov_common/models/report.dart'; @@ -68,7 +69,7 @@ class PollService { .map((vote) => vote.commentId) .toSet(); - // Fetch all comments. + // Fetch all top-level comments. final commentsResponse = (await _connection.query( _pollCommentsQuery, substitutionValues: { @@ -77,7 +78,7 @@ class PollService { }, )) .mapRows(Comment.fromJson) - .where((comment) => user.isAdmin || comment.isApproved); + .where((comment) => (user.isAdmin || comment.isApproved) && comment.parentId == 0); // For all comments the user has voted on, fetch the report details. final comments = await Future.wait(commentsResponse.map((comment) async => @@ -86,8 +87,42 @@ class PollService { : comment)); return Response.ok( - json.encode(PollDetailsResponse(poll: poll, comments: comments))); + json.encode(PollDetailsResponse(parent: poll, messages: comments))); } + + @Route.get('/details/comment/') + Future getCommentReplies(Request request) async { + final user = await request.decodeAuth(_connection); + + if (user == null) { + return Response.forbidden(null); + } + + final commentId = int.parse(request.params['commentId']!); + + final comment = Comment.fromJson( + (await _connection.select('comment', where: {'id': commentId})).single); + + // Fetch all of the IDs of replies the user has voted on. + final votedReplyIds = + (await _connection.select('vote', where: {'user_id': user.id})) + .map(Vote.fromJson) + .map((vote) => vote.commentId) + .toSet(); + + // Fetch all replies + final repliesResponse = (await _connection.select( + 'comment', where: {'parent_id': commentId} + )).map(Comment.fromJson).where((reply) => user.isAdmin || reply.isApproved); + + // For all replies the user has voted on, fetch the report details. + final replies = await Future.wait(repliesResponse.map((reply) async => + votedReplyIds.contains(reply.id) + ? await _addStats(reply) + : reply)); + + return Response.ok(json.encode(CommentDetailsResponse(parent:comment, messages:replies))); + } @Route.get('/comment/') Future getCommentDetails(Request request) async { @@ -105,7 +140,7 @@ class PollService { return Response.ok(json.encode(comment)); } - @Route.get('/report/') + @Route.get('/report//') Future getReport(Request request) async { final user = await request.decodeAuth(_connection); @@ -114,11 +149,12 @@ class PollService { } final pollId = int.parse(request.params['pollId']!); - + final parentId = int.parse(request.params['parentId']!); + final commentsResponse = - (await _connection.select('comment', where: {'poll_id': pollId})) + (await _connection.select('comment', where: {'poll_id': pollId, 'parent_id' : parentId})) .map(Comment.fromJson) - .where((comment) => user.isAdmin || comment.isApproved); + .where((comment) => (user.isAdmin || comment.isApproved)); final comments = await Future.wait(commentsResponse.map(_addStats)); @@ -159,6 +195,7 @@ class PollService { final dbResponse = await _connection.insert('comment', { 'poll_id': poll.id, + 'parent_id': addCommentRequest.parentId, 'user_id': user.id, 'comment': addCommentRequest.comment, 'timestamp': DateTime.now().millisecondsSinceEpoch, diff --git a/server/lib/services/poll_service.g.dart b/server/lib/services/poll_service.g.dart index 7052eee..fa65a82 100644 --- a/server/lib/services/poll_service.g.dart +++ b/server/lib/services/poll_service.g.dart @@ -10,8 +10,9 @@ Router _$PollServiceRouter(PollService service) { final router = Router(); router.add('GET', r'/list', service.listPolls); router.add('GET', r'/details/', service.getPollDetails); + router.add('GET', r'/details/comment/', service.getCommentReplies); router.add('GET', r'/comment/', service.getCommentDetails); - router.add('GET', r'/report/', service.getReport); + router.add('GET', r'/report//', service.getReport); router.add('POST', r'/add-comment', service.addComment); router.add('POST', r'/vote', service.vote); return router; diff --git a/server/schema.sql b/server/schema.sql index 3005ba5..0d01afd 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -12,6 +12,7 @@ CREATE TABLE comment ( id SERIAL, poll_id INTEGER NOT NULL, + parent_id INTEGER, user_id INTEGER, comment TEXT NOT NULL, timestamp BIGINT NOT NULL,