GrandPrix Circus - likes and comments

11 minute read

Introduction

In this part of the series, I will build the social features for the application. The user will have the possibility to like or dislike a post and leave a comment. The post will have the initial state with likes and dislikes fetched from the database. If the user reacts it will change this state, but won’t make a new GET request. This allows the app to avoid a situation where the user likes the post, but other users dislike it, so in the interface, the count does not change. To obtain the current real number of reactions the user needs to refresh the posts list. In the production application, I would also implement a rate limiting to avoid spamming with reactions. Comments will trigger a new GET request because, in my opinion, it is natural for the user to be aware of the current state of the discussion if they are participating in it.

Let’s get to work!

Data and domain layer

The content repository methods were shown in the previous post, so I will just paste them here again, but won’t show the full implementation of the class.

 @override
  Future<void> dislikePost(String postId, String userId) async {
    await _dio.put('$_baseUrl/content/$postId/dislike?user_id=$userId',
        options: _options);
  }

  @override
  Future<void> likePost(String postId, String userId) async {
    await _dio.put('$_baseUrl/content/$postId/like?user_id=$userId',
        options: _options);
  }

  @override
  Future<ReactionModel> getUserPostReaction(
      String postId, String userId) async {
    Response res = await _dio.get('$_baseUrl/reactions/$postId-$userId',
        options: _options);
    ReactionModel reaction = ReactionModel.fromJson(res.data);
    return reaction;
  }

  @override
  Future<void> createComment(CommentModel commentModel) async {
    await _dio.post('$_baseUrl/comments/',
        data: commentModel.toJson(), options: _options);
  }

  @override
  Future<void> deleteComment(String commentId) async {
    await _dio.delete('$_baseUrl/comments/$commentId', options: _options);
  }

  @override
  Future<List<CommentModel>> getPostComments(String postId, int page) async {
    Response res = await _dio.get('$_baseUrl/comments/post/$postId/?page=$page',
        options: _options);
    final responseData = res.data['comments'] as List<dynamic>;
    List<CommentModel> commentsList = responseData.map((postJson) {
      return CommentModel.fromJson(postJson as Map<String, dynamic>);
    }).toList();
    return commentsList;
  }

  @override
  Future<List<CommentModel>> getUserPostComments(
      String postId, String userId) async {
    Response res = await _dio.get(
        '$_baseUrl/comments/user-posts/$postId-$userId',
        options: _options);
    final responseData = res.data['comments'] as List<dynamic>;
    List<CommentModel> commentsList = responseData.map((postJson) {
      return CommentModel.fromJson(postJson as Map<String, dynamic>);
    }).toList();
    return commentsList;
  }

  @override
  Future<void> updateComment(
      String commentId, CommentModel commentModel) async {
    await _dio.put('$_baseUrl/comments/$commentId',
        data: commentModel.toJson(), options: _options);
  }
}

As you can notice I use there two models - comment and reaction. They are pretty basic and were already implemented for the FastAPI also.

@freezed
class CommentModel with _$CommentModel {
  const factory CommentModel(
      {required String id,
      required String userId,
      required String userName,
      required String postId,
      required String comment,
      required DateTime creationTime}) = _CommentModel;

  factory CommentModel.fromJson(Map<String, Object?> json) =>
      _$CommentModelFromJson(json);
}
@freezed
class ReactionModel with _$ReactionModel {
  const factory ReactionModel(
      {required String id,
      required String userId,
      required String postId,
      required int reactionType}) = _ReactionModel;

  factory ReactionModel.fromJson(Map<String, Object?> json) =>
      _$ReactionModelFromJson(json);
}

The tricky one is the third model. The post with reactions can be displayed on the two screens - the scrolling list and the post view and must keep the same state. I decided to create the combined model - post_reactions_state_model that will keep all fields required to display the correct reactions for the chosen post.

@freezed
class PostReactionsStateModel with _$PostReactionsStateModel {
  const factory PostReactionsStateModel(
      {required String postId,
      required String? userId,
      required int likes,
      required int dislikes,
      required int userReactionType}) = _PostReactionsStateModel;

  factory PostReactionsStateModel.fromJson(Map<String, Object?> json) =>
      _$PostReactionsStateModelFromJson(json);
}

If the user reacts on the one view, the other view can’t display the reactions incorrectly.

Controllers

The controllers manage the changes in the state. Reactions and comments need to use the network, so it is a good idea to create AsyncNotifiers, which are asynchronously initialized.

The ReactionsController has a state of future or PostReactionsStateModel type. While initialization it asks the API if the user already gave the reaction for the post. If not (404 error) the reaction type is set to -1.

@riverpod
class ReactionsController extends _$ReactionsController {
  @override
  FutureOr<PostReactionsStateModel> build(
      PostModel post, String? userId) async {
    int reactionType = -1;

    if (userId != null) {
      try {
        final ReactionModel reaction = await ref
            .read(contentAPIProvider)
            .getUserPostReaction(post.id, userId);

        reactionType = reaction.reactionType;
      } catch (error) {
        reactionType = -1;
      }
    }

    return PostReactionsStateModel(
        postId: post.id,
        userId: userId,
        likes: post.likesCount,
        dislikes: post.dislikesCount,
        userReactionType: reactionType);
  }

The methods for liking and disliking are similar. For example, likes:

  • if the current user reaction type is 0 (disliked) it changes the type to 1 (liked), increases the likes counter, and decreases the dislikes counter,
  • if the reaction type is 1, it changes the type to -1 (not set) and decreases the likes counter if it is larger than 0,
  • if the reaction type is not set it just increases the likes and changes the type to the correct one,
  • the method changes the state to the copy of the current state model with the changes or displays the dialog and keeps the current state on error.
  Future<void> like(BuildContext context, String postId, String userId) async {
    final contentAPI = ref.read(contentAPIProvider);

    final currentModel = state.value;
    int reactionType = currentModel!.userReactionType;
    int likes = currentModel.likes;
    int dislikes = currentModel.dislikes;

    try {
      await contentAPI.likePost(postId, userId);

      if (reactionType == 0) {
        reactionType = 1;
        likes += 1;
        dislikes -= 1;
      } else if (reactionType == 1) {
        if (likes > 0) {
          likes -= 1;
        }
        reactionType = -1;
      } else {
        reactionType = 1;
        likes += 1;
      }
      state = AsyncValue.data(currentModel.copyWith(
          userReactionType: reactionType, likes: likes, dislikes: dislikes));
    } catch (err) {
      if (context.mounted) {
        showErrorDialog(context);
      }
      state = AsyncValue.data(currentModel);
    }
  }

  Future<void> dislike(
      BuildContext context, String postId, String userId) async {
    final contentAPI = ref.read(contentAPIProvider);

    final currentModel = state.value;
    int reactionType = currentModel!.userReactionType;
    int likes = currentModel.likes;
    int dislikes = currentModel.dislikes;

    try {
      await contentAPI.dislikePost(postId, userId);

      if (reactionType == 1) {
        reactionType = 0;
        likes -= 1;
        dislikes += 1;
      } else if (reactionType == 0) {
        if (dislikes > 0) {
          dislikes -= 1;
        }
        reactionType = -1;
      } else {
        reactionType = 0;
        dislikes += 1;
      }

      state = AsyncValue.data(currentModel.copyWith(
          userReactionType: reactionType, likes: likes, dislikes: dislikes));
    } catch (err) {
      if (context.mounted) {
        showErrorDialog(context);
      }
      state = AsyncValue.data(currentModel);
    }
  }

As you can notice I do not set state to the loading while making a HTTP request. The reason is I do not want to show any indicators for reactions. It is a quick action, but it could cause weird interface blinking. I am not using the AsyncGuard here, because I do not want to set the error state also, just display the dialog to the user. I think it is hacking the Riverpod a bit. For the widget, my controller is always in the “data state”. It displays the new reaction state if the network communicated worked or the previous state and shows the dialog if not. It would be really weird to show the progress indicators or error messages in the place for the reaction thumbs.

The comments controller can use the AsyncGuard freely. It is pretty normal to display the progress indicator while the comments are loading or the error message if something goes wrong. The initial state is the future or list - empty or with fetched comments, for the first page in the API.

@riverpod
class CommentsController extends _$CommentsController {
  Future<List> _fetchPostComments(String postId, int pageNumber) async {
    late Future<List> contentList;

    try {
      contentList =
          ref.read(contentAPIProvider).getPostComments(postId, pageNumber);
    } catch (e) {
      contentList = Future(() => []);
    }

    return contentList;
  }

  @override
  FutureOr<List> build(String postId) => _fetchPostComments(postId, 1);

The first method is responsible for getting to the next page. The comments won’t be displayed using infinite scroll, the view will have classic next/previous buttons.

  Future<void> getNextPage(pageNumber) async {
    state = const AsyncLoading();

    state = await AsyncValue.guard(() {
      return _fetchPostComments(postId, pageNumber);
    });
  }

The rest ones are classic CRUD methods for adding, updating and deleting the comment.

 Future<void> addComment(CommentModel commentModel) async {
    final contentAPI = ref.read(contentAPIProvider);

    state = const AsyncLoading();

    state = await AsyncValue.guard(() {
      contentAPI.createComment(commentModel);

      return _fetchPostComments(postId, 1);
    });
  }

  Future<void> updateComment(
      String commentId, CommentModel commentModel) async {
    final contentAPI = ref.read(contentAPIProvider);

    state = const AsyncLoading();

    state = await AsyncValue.guard(() {
      contentAPI.updateComment(commentId, commentModel);

      return _fetchPostComments(postId, 1);
    });
  }

  Future<void> deleteComment(String commentId) async {
    final contentAPI = ref.read(contentAPIProvider);

    state = const AsyncLoading();

    state = await AsyncValue.guard(() {
      contentAPI.deleteComment(commentId);

      return _fetchPostComments(postId, 1);
    });
  }
}

All of the methods set the state to the newly fetched post for the first page, so the user will be redirected there no matter which page is currently opened.

Presentation layer

Likes

The likes widget requires the input parameters - post and user identifier. It watches for the changes in the reaction controller created above. If it has data (it always has - hack), it renders a row with two inkwells with thumbs-up and down icons. The icons are outlined or not based on the reaction type. If the user is not authenticated the dialog with the warning is displayed. If the user is authenticated the proper controller method is triggered.

class Likes extends ConsumerWidget {
  final PostModel post;
  final String? userId;

  const Likes(this.post, this.userId, {super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = reactionsControllerProvider(post, userId);

    return ref.watch(controller).when(
          data: (data) {
            int reactionType = data.userReactionType;
            int likes = data.likes;
            int dislikes = data.dislikes;

            return Row(
              children: [
                Text(
                  likes.toString(),
                  style: const TextStyle(fontSize: 12),
                ),
                const SizedBox(width: 4),
                InkWell(
                  onTap: () {
                    if (userId != null) {
                      ref
                          .read(controller.notifier)
                          .like(context, post.id, userId!);
                    } else {
                      showCreateAccountDialog(context);
                    }
                  },
                  child: reactionType == 1
                      ? const Icon(Icons.thumb_up)
                      : const Icon(Icons.thumb_up_outlined),
                ),
                const SizedBox(width: 8),
                InkWell(
                    onTap: () {
                      if (userId != null) {
                        ref
                            .read(controller.notifier)
                            .dislike(context, post.id, userId!);
                      } else {
                        showCreateAccountDialog(context);
                      }
                    },
                    child: reactionType == 0
                        ? const Icon(Icons.thumb_down)
                        : const Icon(Icons.thumb_down_outlined)),
                const SizedBox(width: 4),
                Text(
                  dislikes.toString(),
                  style: const TextStyle(fontSize: 12),
                ),
              ],
            );
          },
          error: (error, stackTrace) {
            return Container();
          },
          loading: () => Container(),
        );
  }
}

As you can notice I return the empty containers on loading and error states. The Riverpod syntax requires handling these states, but in real life, they never happen. The account dialog implementation looks like this:

void showCreateAccountDialog(BuildContext context) {
  showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        title: const Text('Warning šŸšØ'),
        content: const Text('Please sign in to access social features!'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('OK'),
          ),
        ],
      );
    },
  );
}

Comments

The comment widget is a consumer that requires the comment model, comments controller, and information if the user interactions are enabled (for example the user should not be able to remove posts from other users). It is a simple column that contains rows with timestamps, user names, interaction icons, and the text field. For simplicity, I just obtain the name from an email, but in the production app, I would ask the user for the unique name during the registration process. I implemented only the delete post interaction, but the update one could be implemented also.

class Comment extends ConsumerWidget {
  final CommentModel commentModel;
  final bool userInteractionsEnabled;
  final CommentsControllerProvider controller;
  const Comment(
      {Key? key,
      required this.commentModel,
      required this.userInteractionsEnabled,
      required this.controller})
      : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(
              '${DateFormat('dd.MM.yy HH:mm').format(commentModel.creationTime)} by ${commentModel.userName}',
              style: const TextStyle(fontSize: 12),
            ),
            userInteractionsEnabled
                ? InkWell(
                    onTap: () {
                      ref
                          .read(controller.notifier)
                          .deleteComment(commentModel.id);
                    },
                    child: const Icon(Icons.delete_forever_outlined),
                  )
                : const SizedBox()
          ],
        ),
        Row(mainAxisAlignment: MainAxisAlignment.start, children: [
          Expanded(
            child: Text(
              commentModel.comment,
              style: const TextStyle(fontSize: 11),
              softWrap: true,
            ),
          ),
        ]),
        const SizedBox(height: 8)
      ],
    );
  }
}

The comments widget is a list of all comments with the text form below. The whole post view is scrollable, so the comments list does not have scrollable physics. The widget watches for changes in the comments controller and current user provider. It refreshes the whole list if the controller methods are triggered. If the user is not authenticated it displays a dialog. It also has the internal state with the current page. It is changed if the user clicks the buttons or provides a new comment. If there are no more pages the buttons are deactivated. The comment length is limited to 120 chars.

class Comments extends ConsumerStatefulWidget {
  final String postId;
  const Comments({super.key, required this.postId});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() => _CommentsState();
}

class _CommentsState extends ConsumerState<Comments> {
  final commentController = TextEditingController();
  int currentPage = 1;

  @override
  void dispose() {
    super.dispose();
    commentController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final controller = commentsControllerProvider(widget.postId);
    final currentUser = ref.watch(authAPIProvider).currentUser;

    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            "Comments",
            style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
          ),
          ref.watch(controller).when(
                data: (data) {
                  return Column(
                    children: [
                      data.isNotEmpty
                          ? ListView.builder(
                              itemCount: data.length,
                              physics: const NeverScrollableScrollPhysics(),
                              shrinkWrap: true,
                              itemBuilder: (context, index) {
                                return Comment(
                                    commentModel: data[index],
                                    userInteractionsEnabled:
                                        data[index].userId == currentUser?.uid
                                            ? true
                                            : false,
                                    controller: controller);
                              },
                            )
                          : const Row(
                              mainAxisAlignment: MainAxisAlignment.start,
                              children: [
                                Text(
                                  "Nothing here yet šŸ™\nFeel free to add a comment! šŸ‘Š",
                                  style: TextStyle(fontSize: 11),
                                ),
                                SizedBox(
                                  height: 8,
                                )
                              ],
                            ),
                      Row(
                        children: [
                          CommonButton(
                              onTap: () {
                                if (currentPage - 1 < 1) {
                                  return;
                                }

                                setState(() {
                                  currentPage -= 1;
                                });

                                ref
                                    .read(controller.notifier)
                                    .getNextPage(currentPage);
                              },
                              label: "Back"),
                          const SizedBox(
                            width: 4,
                          ),
                          CommonButton(
                              onTap: () {
                                if (data.length < 5) {
                                  return;
                                }

                                setState(() {
                                  currentPage += 1;
                                });

                                ref
                                    .read(controller.notifier)
                                    .getNextPage(currentPage);
                              },
                              label: "Next"),
                        ],
                      ),
                      const SizedBox(height: 8),
                      Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: TextFormField(
                          maxLines: null,
                          maxLength: 120,
                          keyboardType: TextInputType.text,
                          decoration: InputDecoration(
                            hintText: 'Add a comment',
                            contentPadding: const EdgeInsets.symmetric(
                                horizontal: 8, vertical: 8),
                            prefixIcon: const Icon(Icons.comment),
                            suffixIcon: IconButton(
                                onPressed: () {
                                  if (currentUser == null) {
                                    showCreateAccountDialog(context);
                                    return;
                                  }

                                  if (commentController.text.isEmpty) {
                                    return;
                                  }

                                  ref.read(controller.notifier).addComment(
                                      CommentModel(
                                          id: '',
                                          userId: currentUser.uid,
                                          userName: getNameFromEmail(
                                              currentUser.email!),
                                          postId: widget.postId,
                                          comment: commentController.text,
                                          creationTime: DateTime.now()));

                                  commentController.clear();

                                  setState(() {
                                    currentPage = 1;
                                  });
                                },
                                icon: const Icon(Icons.send)),
                            focusedBorder: OutlineInputBorder(
                              borderRadius: BorderRadius.circular(8),
                              borderSide: const BorderSide(width: 1),
                            ),
                            enabledBorder: OutlineInputBorder(
                              borderRadius: BorderRadius.circular(8),
                              borderSide: const BorderSide(width: 1),
                            ),
                          ),
                          controller: commentController,
                        ),
                      ),
                    ],
                  );
                },
                error: (error, stackTrace) => Text(error.toString()),
                loading: () => const Center(child: CircularProgressIndicator()),
              ),
        ],
      ),
    );
  }
}

As you can see I handle error and loading states there. In error, the message is displayed and the progress indicator is on loading.

comments

no_comments

Conclusion

The Riverpod makes handling the asynchronous states an easy task, but there are some tradeoffs. I also think that there are too few examples in the documentation for code generation. The community discussion is so big also. I think, in the next project I will try the other state management solution - BloC, which is the most popular one. The application prototype is almost done. In the next (and last) post of the series, I will create the future for creating posts by the user. Cya there!