GrandPrix Circus - post and infinite scroll

13 minute read

Introduction

In this post, I will create an endless scrolling list of posts. The content will be obtained using the API I created in the first post of the series. It is paginated, one page has 10 posts, so the list will follow this also. The mainly used dart packages will be - infinite_scroll_pagination and webview_flutter. Embedding the social media posts in the web view is quite tricky, but the post should not be so long.

Let’s get to work!

Data and domain layer

The first model to create is a post. It has literally the same fields I defined in the API service.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'post_model.freezed.dart';
part 'post_model.g.dart';

@freezed
class PostModel with _$PostModel {
  const factory PostModel(
      {required String id,
      required DateTime creationTime,
      required String author,
      required String title,
      required String contentId,
      required String tag,
      required String source,
      required int likesCount,
      required int dislikesCount}) = _PostModel;

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

As you can see I use the freezed package, which is a code generator for immutable classes. It implements a lot of boilerplate code I would create, for example, toString, copyWithor de/serialization methods.

The models for reactions and comments are also required and the same as in the API.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'comment_model.freezed.dart';
part 'comment_model.g.dart';

@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);
}
import 'package:freezed_annotation/freezed_annotation.dart';

part 'reaction_model.freezed.dart';
part 'reaction_model.g.dart';

@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);
}

In the future post I will also create a separate model for managing the state of a post with reactions (I mean it will keep the information about current user likes or dislikes.

Let’s create an abstract interface for the repository responsible for communicating with MongoDB.

abstract class IContentAPI {
  Future<List<PostModel>> getContent(int page);
  Future<PostModel> getPost(String postId);
  Future<void> createPost(PostModel postModel);
  Future<void> updatePost(String postId, PostModel postModel);
  Future<void> deletePost(String postId);
  Future<void> likePost(String postId, String userId);
  Future<void> dislikePost(String postId, String userId);
  Future<ReactionModel> getUserPostReaction(String postId, String userId);
  Future<void> createComment(CommentModel commentModel);
  Future<List<CommentModel>> getPostComments(String postId, int page);
  Future<List<CommentModel>> getUserPostComments(String postId, String userId);
  Future<void> updateComment(String commentId, CommentModel commentModel);
  Future<void> deleteComment(String commentId);
}

It has already all methods defined, not everyone will be shown in this post. The repository needs to have the base URL and options with the API key created.

class ContentAPI implements IContentAPI {
  final Dio _dio;
  final String _baseUrl = 'https://grandprixcircus-api.brozen.best';

  final Options _options = Options(headers: {
    'x-api-key':
        '78fd34f798c86253f8e52d40cf6fce3726b08e401468643ee5b7e120c734576a',
  });

  ContentAPI({required Dio dio}) : _dio = dio;

It required the Dio instance. I created a common provider:

@riverpod
Dio dioClient(DioClientRef ref) {
  Dio dio = Dio();
  return dio;
}

The methods are pretty simple. They perform basic CRUD operations. They do not even have an error handling, because It is possible to comfortably manage it in the widgets thanks to the Riverpod.

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

  @override
  Future<PostModel> getPost(String postId) async {
    Response res =
        await _dio.get('$_baseUrl/content/$postId', options: _options);
    PostModel post = PostModel.fromJson(res.data);
    return post;
  }

  @override
  Future<void> createPost(PostModel postModel) async {
    await _dio.post('$_baseUrl/content/',
        data: postModel.toJson(), options: _options);
  }

  @override
  Future<void> updatePost(String postId, PostModel postModel) async {
    await _dio.put('$_baseUrl/content/$postId',
        data: postModel.toJson(), options: _options);
  }

  @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> deletePost(String postId) async {
    await _dio.delete('$_baseUrl/content/$postId', options: _options);
  }

  @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);
  }
}

All of the methods are presented, but as you already know, I won’t use everyone in this post. To be honest I will only use getContent methods. The last thing to do is create a provider for the repository:

@riverpod
ContentAPI contentAPI(ContentAPIRef ref) {
  return ContentAPI(dio: ref.watch(dioClientProvider));
}

Infinite scroll list

The infinite scroll list widget shall initiate with the paging controller that monitors the state of paginated data and requests extra data when notified.

class PostsList extends ConsumerStatefulWidget {
  const PostsList({super.key});

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

class _PostsListState extends ConsumerState<PostsList> {
  static const _pageSize = 10;
  final PagingController<int, PostModel> _pagingController =
      PagingController(firstPageKey: 1);

  @override
  void initState() {
    _pagingController.addPageRequestListener((pageKey) {
      _fetchPage(pageKey);
    });
    super.initState();
  }

It requests the first page during the building process, but it also has a _fetchPage function that helps with requesting the next pages.

  Future<void> _fetchPage(int pageKey) async {
    try {
      final newItems = await ref.watch(contentAPIProvider).getContent(pageKey);

      final isLastPage = newItems.length < _pageSize;
      if (isLastPage) {
        _pagingController.appendLastPage(newItems);
      } else {
        final nextPageKey = pageKey + 1;
        _pagingController.appendPage(newItems, nextPageKey);
      }
    } catch (error) {
      _pagingController.error = error;
    }
  }

Now let’s build a widget. The PagedListView is responsible for the data on the page with controller received. The PageChildBuilderDelegate takes care of each item in the view and handles its state, for example handling loading and errors. I will show the progress indicators on loading the first page and every new page (obviously if it is required). If the user reaches the end of the last page the list shows the message and a button that refreshes the controller. I have also wrapped everything in the refresh indicator, so it is possible to pull to refresh. The builder also listens to the mode and authentication state providers and refreshes on changes. I need it because tweets will have a light and dark mode in the web view also and the post widget will require authentication to perform the social actions. The cacheExtent field means that the list will build the items 8000 pixels away from the current viewport.

  @override
  Widget build(BuildContext context) {
    ref.listen(darkModeControllerProvider, (previous, next) {
      Future.sync(() => _pagingController.refresh());
    });

    ref.listen(authStateChangesProvider, (previous, next) {
      Future.sync(() => _pagingController.refresh());
    });

    return RefreshIndicator(
        onRefresh: () => Future.sync(
              () => _pagingController.refresh(),
            ),
        child: PagedListView<int, PostModel>(
          cacheExtent: 8000,
          pagingController: _pagingController,
          builderDelegate: PagedChildBuilderDelegate<PostModel>(
              firstPageProgressIndicatorBuilder: (context) =>
                  const Center(child: CircularProgressIndicator()),
              newPageProgressIndicatorBuilder: (context) =>
                  const Center(child: CircularProgressIndicator()),
              noMoreItemsIndicatorBuilder: (context) => Column(
                    children: [
                      const Text("You reached end of the internet!"),
                      IconButton(
                        onPressed: () {
                          Future.sync(() => _pagingController.refresh());
                        },
                        icon: const Icon(Icons.refresh),
                      )
                    ],
                  ),
              itemBuilder: (context, item, index) => PostWidget(
                    post: item,
                    clickable: true,
                  )),
        ));
  }

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

Post

Embedded media

The main problem with the embedded social media posts is the height of the container is unknown before the widget is rendered. I set it to 700 by default. The widget can receive a message from the content inside it using a JavaScript channel and change the height state. It will be sent after the content is rendered. The rest of the code is pretty basic. The navigation is prevented if the content launches the URL. The background color is transparent.

class EmbededMedia extends StatefulWidget {
  final String embedHTML;

  const EmbededMedia({Key? key, required this.embedHTML}) : super(key: key);

  @override
  _EmbededMediaState createState() => _EmbededMediaState();
}

class _EmbededMediaState extends State<EmbededMedia> {
  late final WebViewController controller;
  double boxHeight = 700;

  @override
  void initState() {
    super.initState();
    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setBackgroundColor(Colors.transparent)
      ..addJavaScriptChannel("boxHeight",
          onMessageReceived: (JavaScriptMessage message) {
        if (mounted) {
          setState(() {
            boxHeight = double.parse(message.message);
          });
        }
      })
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (NavigationRequest request) async {
            Uri url = Uri.parse(request.url);
            if (request.isMainFrame && await canLaunchUrl(url)) {
              launchUrl(url);
              return NavigationDecision.prevent;
            } else {
              return NavigationDecision.navigate;
            }
          },
        ),
      )
      ..loadHtmlString(widget.embedHTML);
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        width: MediaQuery.of(context).size.width,
        height: boxHeight,
        child: WebViewWidget(controller: controller));
  }

As you can notice it loads the HTML passed to the widget as an argument. Let’s create the functions that will create an HTML string for tweets and Instagram posts.

Twitter has a JavaScript API for websites. It is pretty nice and makes the process a lot easier. It is important to have a viewport meta with the content set to device width and initial scale = 1. The content shouldn’t be enormously big on the mobile device. It is also possible to set the theme, I pass it as a function argument. Same with the width. The Twitter-js has also event listeners, so it is possible to react when the ‘rendered’ action happens. The problem is it often triggers when the content is not fully rendered and gives too small an offset height. I have created a resize observer that will send height to the application when the content resizes. Sometimes, this results in a strange jump during the widget loading, but it’s better than loading incomplete content.

String generateTwitterHTMLString(
    String tweetId, String colorMode, double tweetWidth) {
  return """
        <html>
        <head>3
          <meta name="viewport" content="width=device-width, initial-scale=1">
        </head>
        
        <body>
          <div id="widget"></div>
        </body>
        <script id="twitter-wjs" type="text/javascript" async defer src="https://platform.twitter.com/widgets.js" onload="createMyTweet()"></script>
        <script>
          function createMyTweet() {
            var twttr = window.twttr;

            twttr.widgets.createTweet(
              '$tweetId',
              document.getElementById('widget'), {
                theme: '$colorMode',
                conversation: 'none',
                width: $tweetWidth,
                align: 'center'
              }
            )

            twttr.ready(

              function(twttr) {
                twttr.events.bind('rendered', function(event) {

                  var tweetElement = event.target;
                  var tweetH = tweetElement.offsettHeight;
                  var widget = document.getElementById("widget");
                  widget.style.height = tweetH + 'px';

                  var sendHeight = () => boxHeight.postMessage(widget.offsetHeight + 10);
                  const onWidgetResize = (widgets) => sendHeight();
                  const resize_ob = new ResizeObserver(onWidgetResize);
                  resize_ob.observe(widget);
                });
              }
            );
          }
        </script>
      </html>
    """;
}

Instagram is not as nice as Twitter and the best way I find to embed the post is by having not really readable blockquote pasted directly. It also does not support the dark mode (in 2023). There is the same problem with sizing solved in the same way - with the observer.

String generateInstaHTMLString(
    String postId, double instaWidth, String author, String source) {
  return """
      <html>
        <head>
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <style>
            *{box-sizing: border-box;margin:0px; padding:0px;}
              #widget {
                        display: flex;
                        justify-content: center;
                        margin: 10px auto;
                        max-width:${instaWidth}px;
                    }      
          </style>
        </head>
        <body>
          <div id="widget">
          <blockquote class="instagram-media" data-instgrm-captioned data-instgrm-permalink="https://www.instagram.com/p/$postId/?utm_source=ig_embed&amp;utm_campaign=loading" data-instgrm-version="14" style=" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:540px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);"><div style="padding:16px;"> <a href="https://www.instagram.com/p/$postId/?utm_source=ig_embed&amp;utm_campaign=loading" style=" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;" target="_blank"> <div style=" display: flex; flex-direction: row; align-items: center;"> <div style="background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;"></div> <div style="display: flex; flex-direction: column; flex-grow: 1; justify-content: center;"> <div style=" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;"></div> <div style=" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;"></div></div></div><div style="padding: 19% 0;"></div> <div style="display:block; height:50px; margin:0 auto 12px; width:50px;"><svg width="50px" height="50px" viewBox="0 0 60 60" version="1.1" xmlns="https://www.w3.org/2000/svg" xmlns:xlink="https://www.w3.org/1999/xlink"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g transform="translate(-511.000000, -20.000000)" fill="#000000"><g><path d="M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631"></path></g></g></g></svg></div><div style="padding-top: 8px;"> <div style=" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;">View this post on Instagram</div></div><div style="padding: 12.5% 0;"></div> <div style="display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;"><div> <div style="background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);"></div> <div style="background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;"></div> <div style="background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);"></div></div><div style="margin-left: 8px;"> <div style=" background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 20px; width: 20px;"></div> <div style=" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)"></div></div><div style="margin-left: auto;"> <div style=" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);"></div> <div style=" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);"></div> <div style=" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);"></div></div></div> <div style="display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;"> <div style=" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;"></div> <div style=" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;"></div></div></a><p style=" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;"><a href="https://www.instagram.com/p/$postId/?utm_source=ig_embed&amp;utm_campaign=loading" style=" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;" target="_blank">A post shared $author ($source)</a></p></div></blockquote> 
          <script async src="https://www.instagram.com/embed.js"></script>
          </div>
          <script>
            const widget = document.getElementById('widget');
            const sendHeight = () => boxHeight.postMessage(widget.scrollHeight);
            const onWidgetResize = (widgets) => sendHeight();
            const resize_ob = new ResizeObserver(onWidgetResize);
            resize_ob.observe(widget);
          </script>
        </body>
      </html>
""";
}

Post widget

The post widget is embedded in social media in the web view or just a simple image. It watches for changes in dark mode and current user providers. It can be clickable or not. That’s because if the widget is displayed on the list, the user can click on the title which will redirect to the post view with comments, etc. On the post view, it should not be clickable. Under the photo, the tags, likes and comments icons are displayed. Clicking in the comments also redirects to the post view.

class PostWidget extends ConsumerWidget {
  final PostModel post;
  final bool clickable;

  const PostWidget({Key? key, required this.post, required this.clickable})
      : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final darkMode = ref.watch(darkModeControllerProvider);
    final currentUser = ref.watch(authAPIProvider).currentUser;

    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        children: [
          GestureDetector(
            onTap: () {
              if (!clickable) {
                return;
              }

              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (BuildContext context) => PostView(
                    post: post,
                  ),
                ),
              );
            },
            child: Row(
              mainAxisAlignment: MainAxisAlignment.start,
              children: [
                SizedBox(
                  width: MediaQuery.of(context).size.width * 0.8,
                  child: Text(
                    post.title,
                    style: const TextStyle(
                        fontWeight: FontWeight.bold, fontSize: 16),
                    softWrap: true,
                    maxLines: 2,
                  ),
                )
              ],
            ),
          ),
          post.tag == 'users'
              ? Column(
                  children: [
                    const SizedBox(height: 8),
                    Image.network(
                      post.contentId,
                      width: MediaQuery.of(context).size.width * 0.85,
                    )
                  ],
                )
              : EmbededMedia(
                  embedHTML: () {
                    if (post.tag == "tweet") {
                      return generateTwitterHTMLString(
                        post.contentId,
                        darkMode ? "dark" : "light",
                        MediaQuery.of(context).size.width * 0.85,
                      );
                    } else if (post.tag == "insta") {
                      return generateInstaHTMLString(
                        post.contentId,
                        MediaQuery.of(context).size.width * 0.85,
                        post.author,
                        post.source,
                      );
                    } else {
                      return 'HTML for other medias';
                    }
                  }(),
                ),
          const SizedBox(
            height: 8,
          ),
          Row(
            children: [
              Text(
                style: const TextStyle(
                  fontSize: 12,
                ),
              ),
              Hashtag(tag: post.tag),
              const Spacer(),
              Likes(post, currentUser?.uid),
              const SizedBox(
                width: 8,
              ),
              InkWell(
                onTap: () {
                  Navigator.push(
                    context,
                    MaterialPageRoute(
                      builder: (BuildContext context) => PostView(
                        post: post,
                      ),
                    ),
                  );
                },
                child: const Icon(Icons.comment),
              ),
              SizedBox(
                width: MediaQuery.of(context).size.width * 0.075,
              )
            ],
          ),
        ],
      ),
    );
  }
}

The hashtag is a simple rounded container with a text.

class Hashtag extends ConsumerWidget {
  final String tag;

  const Hashtag({Key? key, required this.tag}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentColorMode =
        stringToColor(ref.watch(colorModeControllerProvider));

    return Container(
      decoration: BoxDecoration(
        color: currentColorMode.withOpacity(0.1),
        borderRadius: BorderRadius.circular(10),
      ),
      child: Padding(
        padding: const EdgeInsets.all(4.0),
        child: FittedBox(
            fit: BoxFit.scaleDown,
            child: Text(
              '#$tag',
              style: const TextStyle(
                fontSize: 12,
              ),
            )),
      ),
    );
  }
}

The post view code:

class PostView extends StatelessWidget {
  final PostModel post;
  const PostView({Key? key, required this.post}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Post šŸ‘€"),
      ),
      body: SingleChildScrollView(
        child: Column(children: [
          PostWidget(
            post: post,
            clickable: false,
          ),
          Comments(postId: post.id)
        ]),
      ),
    );
  }
}

It is just a simple scaffold with a post widget and comments widget. I will show the implementation of likes and comments in the next post. It looks like this in the UI: register

login

user profile

Do not forget to generate the code!

dart run build_runner build

Conclusion

This post was really short. In my opinion, working with the content displayed in the web view is inconvenient. I think it does not look stable if there is a lot of web view on one screen. There are some other plugins for handling web view, for example flutter_inappwebview. I’m sure I will check it in future projects. Cya in the next post!