Making a Full-Stack Blog App with Butter CMS, Dart Frog, Flutter, and AWS, Part II: Building the Web App

Making a Full-Stack Blog App with Butter CMS, Dart Frog, Flutter, and AWS, Part II: Building the Web App

Stefan Hodges-Kluck

by Stefan Hodges-Kluck on April 14, 2024

This is part 2 of a 3-part series on building a full-stack blog app. To catch up, read part 1 first. See the full source code that inspired it on Github

Now that your Dart Frog API is finished and fetching data from Butter CMS, it's time to build the frontend. Before building out the screens, however, we need to make a few packages that will help us follow our layered architecture and separate concerns. Navigate to the project route and create a folder titled packages.

Blog API Client

The first thing we need is an API client that will talk to our Dart Frog backend. In the root packages directory, execute the following command:

very_good create dart_package blog_api_client

Add the http package to this project, since it will be making http calls. Inside the source directory of blog_api_client, make two files: blog_api_client.dart and blog_api.dart. Start by filling in blog_api_client.dart with an extension of the base HTTP client:

import 'dart:io';

import 'package:http/http.dart';

/// {@template blog_api_client}
/// An implementation of an HTTP client to interact with the blog API.
/// {@endtemplate}
class BlogApiClient extends BaseClient {
  /// {@macro blog_api_client}
  BlogApiClient({required Client innerClient}) : _innerClient = innerClient;

  final Client _innerClient;

  @override
  Future<StreamedResponse> send(BaseRequest request) {
    request.headers.putIfAbsent(
      HttpHeaders.contentTypeHeader,
      () => ContentType.json.value,
    );
    request.headers.putIfAbsent(
      HttpHeaders.acceptHeader,
      () => ContentType.json.value,
    );
    return _innerClient.send(request);
  }

  @override
  void close() {
    _innerClient.close();
    super.close();
  }
}

You might be wondering why I'm adding this custom client instead of using the basic Client provided by the HTTP package. Adding this client allows us to consolidate all necessary header logic into a single send method. Instead of adding headers on every API call, we have one place that automatically adds it to any call that implements this client. This is also a place to add any auth headers, if you wanted to add any particular custom authentication to your app. Then, blog_api.dart consumes the api client:

import 'dart:convert';
import 'dart:core';
import 'dart:io';

import 'package:blog_api_client/blog_api_client.dart';
import 'package:blog_models/blog_models.dart';
import 'package:http/http.dart';

/// Generic type representing a JSON factory.
typedef FromJson<T> = T Function(Map<String, dynamic> json);

/// {@template blog_api}
/// An API used for interacting with the blog backend.
/// {@endtemplate}
class BlogApi {
  /// {@macro blog_api}
  BlogApi({required Client client, required String baseUrl})
      : _client = BlogApiClient(innerClient: client),
        _baseUrl = baseUrl;

  final BlogApiClient _client;
  final String _baseUrl;

  /// GET /blogs
  ///
  /// Returns a list of blogs.
  Future<BlogsResponse> getBlogs() async => _sendRequest(
        uri: Uri.parse('$_baseUrl/blogs'),
        fromJson: BlogsResponse.fromJson,
      );

  /// GET /blogs/{slug}
  ///
  /// Returns a blog with the given [slug].
  Future<BlogResponse> getBlog(String slug) async => _sendRequest(
        uri: Uri.parse('$_baseUrl/blogs/$slug'),
        fromJson: BlogResponse.fromJson,
      );

  Future<T> _sendRequest<T>({
    required Uri uri,
    required FromJson<T> fromJson,
    String method = 'GET',
  }) async {
    try {
      final request = Request(method.toUpperCase(), uri);

      final responseStream = await _client.send(request);
      final response = await Response.fromStream(responseStream);
      final responseBody = response.json;

      if (response.statusCode >= 400) {
        throw BlogApiClientFailure(
          statusCode: response.statusCode,
          error: responseBody,
        );
      }

      return fromJson(responseBody);
    } on BlogApiClientMalformedResponse {
      rethrow;
    } on BlogApiClientFailure {
      rethrow;
    } catch (e) {
      throw BlogApiClientFailure(
        statusCode: HttpStatus.internalServerError,
        error: e,
      );
    }
  }
}

extension on Response {
  Map<String, dynamic> get json {
    try {
      final decodedBody = utf8.decode(bodyBytes);
      return jsonDecode(decodedBody) as Map<String, dynamic>;
    } catch (error, stackTrace) {
      Error.throwWithStackTrace(
        BlogApiClientMalformedResponse(error: error),
        stackTrace,
      );
    }
  }
}

The exceptions BlogApiClientMalformedResponse and BlogApiClientFailure are custom, and you will need to add them in a directory titled exceptions. If you want an example of the content they contain, you can check my repo. Also note that we are using models defined in our blog_models package, which will need to be imported into this package.  

Notice the use of a private _sendRequest method to operate all calls. Right now the API has only two GET requests, but by abstracting away common HTTP logic, you can easily add more calls in the future. Now, for instance, if you wanted to add search filtering, the ability to comment on blog posts, or other features, those methods should be as clean and simple as getBlogs and getBlog.

Blog Repository

While the API client gives us the necessary logic to call our backend, we still need to process that data for our UI to display. That work will be done in a repository package. Run the following command in the root packages directory:

very_good create dart_package blog_repository

Fill blog_repository.dart with the following content, making sure to add the blog_models and blog_api_client packages to the project:

import 'package:blog_api_client/blog_api_client.dart';
import 'package:blog_models/blog_models.dart';

/// {@template blog_repository}
/// Repository to process blog data retrieved from the API.
/// {@endtemplate}
class BlogRepository {
  /// {@macro blog_repository}
  BlogRepository({required BlogApi blogApi}) : _blogApi = blogApi;

  final BlogApi _blogApi;

  /// Gets a list of [BlogPreview] objects.
  Future<List<BlogPreview>> getBlogPreviews() async {
    final response = await _blogApi.getBlogs();
    return response.data.map(BlogPreview.fromBlog).toList();
  }

  /// Gets a single [BlogDetail] object given a unique [slug].
  Future<BlogDetail> getBlogDetail({required String slug}) async {
    final response = await _blogApi.getBlog(slug);
    return BlogDetail.fromBlog(response.data);
  }
}

Blog UI

The blog UI package defines theming data such as colors, text styles, spacing, and default dark/light themes. It also houses commonly-used UI widgets, abstracted away from any business logic or knowledge of other data models. Create the UI package with the following command:

very_good create dart_package blog_ui

Then, add any theming and widgets you want into this package in a theme directory (here is an example). Create a widgets directory and add three files for reusable components: author_avatar.dart, author_tile.dart, and featured_image.dart:

import 'package:flutter/material.dart';

/// Widget to display an author's avatar.
class AuthorAvatar extends StatelessWidget {
  /// Default constructor for an author's avatar.
  const AuthorAvatar({required this.imageUrl, super.key});

  /// Url of the author's image.
  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    return CircleAvatar(
      radius: 20,
      backgroundImage: NetworkImage(imageUrl),
    );
  }
}

import 'package:blog_ui/src/theme/theme.dart';
import 'package:blog_ui/src/widgets/widgets.dart';
import 'package:flutter/material.dart';

/// List Tile displaying an author's details.
class AuthorTile extends StatelessWidget {
  /// Default constructor for an author tile.
  const AuthorTile({
    required this.author,
    this.authorImage,
    super.key,
  });

  /// Name of the author.
  final String author;

  /// Optional url of the author's image.
  final String? authorImage;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      contentPadding: BlogSpacing.noPadding,
      leading:
          authorImage != null ? AuthorAvatar(imageUrl: authorImage!) : null,
      title: Text(
        author,
        style: BlogTextStyles.listTileSubtitle.copyWith(
          color: Theme.of(context).colorScheme.secondary,
        ),
      ),
    );
  }
}

import 'package:flutter/material.dart';

/// {@template featured_image}
/// Widget that displays full-width feature image
/// for a blog post.
/// {@endtemplate}
class FeaturedImage extends StatelessWidget {
  /// {@macro featured_image}
  const FeaturedImage({
    required this.imageUrl,
    this.imageTag,
    this.constraints,
    super.key,
  });

  /// Url of the image to display.
  final String imageUrl;

  /// Optional tag of the image.
  final String? imageTag;

  /// Optional constraints to apply to the image.
  final BoxConstraints? constraints;

  @override
  Widget build(BuildContext context) {
    final image = Image.network(
      imageUrl,
      fit: BoxFit.fitWidth,
      semanticLabel: imageTag,
    );

    return constraints != null
        ? ConstrainedBox(
            constraints: constraints!,
            child: image,
          )
        : image;
  }
}

Note the use of static BlogSpacing and BlogTextStyles values, which you should define in your theme directory.

Now add two new files to the widgets directory: blog_card.dart, which stores a widget for displaying preview content in a list form, and blog_detail_content.dart, which stores a widget for rendering scrollable HTML content. Add the following to blog_card.dart:

import 'package:blog_ui/blog_ui.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

/// Widget to display a card with a preview of a blog post.
class BlogCard extends StatelessWidget {
  /// Default constructor for a blog card.
  const BlogCard({
    required this.title,
    required this.subtitle,
    required this.published,
    required this.onTap,
    this.imageUrl,
    this.imageTag,
    super.key,
  });

  /// Title of the card.
  final String title;

  /// Short subtitle of the card.
  final String subtitle;

  /// Date of publication of the blog post.
  final DateTime published;

  /// Callback to call when card is tapped.
  final VoidCallback onTap;

  /// Optional url of a preview image.
  final String? imageUrl;

  /// Unique tag of the card image.
  final String? imageTag;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return GestureDetector(
      onTap: onTap,
      child: Card(
        elevation: 5,
        clipBehavior: Clip.hardEdge,
        margin: BlogSpacing.bottomMargin,
        color: theme.colorScheme.primaryContainer,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            if (imageUrl != null)
              Row(
                children: [
                  Expanded(
                    child: FeaturedImage(
                      imageUrl: imageUrl!,
                      imageTag: imageTag,
                      constraints: const BoxConstraints(
                        maxHeight: 200,
                      ),
                    ),
                  ),
                ],
              ),
            Padding(
              padding: BlogSpacing.allPadding,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  BlogSpacing.largeVerticalSpacing,
                  Text(
                    title,
                    style: BlogTextStyles.cardTitle,
                  ),
                  BlogSpacing.smallVerticalSpacing,
                  Text(
                    subtitle,
                    style: BlogTextStyles.cardSubtitle,
                  ),
                  BlogSpacing.mediumVerticalSpacing,
                  Text(
                    DateFormat('MMMM d, yyyy').format(published),
                    style: BlogTextStyles.cardSubtitle,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Then add the flutter_html package to the Blog UI package. UPDATE: as of April 27, 2024, there is a compilation error in the stable version of flutter html. Upgrading to 3.0.0-alpha-3 or above should fix the issue. You may, however, find issues with the prerelease in older versions of iOS Safari. For me, the best option was to fork the package repository and implement a workaround in my forked version.

EDIT (June 2, 2024): I have since replaced flutter_html with the more recently-maintained flutter_widget_from_html. If you want to see how I implemented that package, you can check out the pull request.

With flutter_html, we can now fill in blog_detail_content.dart:

import 'package:blog_ui/blog_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:html/dom.dart' as dom;
import 'package:intl/intl.dart';

/// {@template blog_detail_content}
/// A widget that displays detailed content of a blog post.
/// {@endtemplate}
class BlogDetailContent extends StatelessWidget {
  /// {@macro blog_detail_content}
  const BlogDetailContent({
    required this.authorName,
    required this.body,
    required this.published,
    required this.slug,
    required this.title,
    this.authorImage,
    this.featuredImage,
    this.onLinkTap,
    super.key,
  });

  /// The full name of the author of the blog post.
  final String authorName;

  /// The body content of the blog post, in stringified HTML format.
  final String body;

  /// The date and time the blog post was published.
  final DateTime published;

  /// The unique identifier of the blog post.
  final String slug;

  /// The title of the blog post.
  final String title;

  /// The URL of the author's profile image.
  final String? authorImage;

  /// The URL of the featured image of the blog post.
  final String? featuredImage;

  /// A callback function that is called when a link in the HTML body is tapped.
  final void Function(String?, Map<String, String>, dom.Element?)? onLinkTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return SingleChildScrollView(
      child: Padding(
        padding: BlogSpacing.horizontalPaddingLarge,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (featuredImage != null) ...[
              Row(
                children: [
                  Expanded(
                    child: FeaturedImage(
                      imageUrl: featuredImage!,
                      imageTag: slug,
                      constraints: const BoxConstraints(
                        maxHeight: 500,
                      ),
                    ),
                  ),
                ],
              ),
              BlogSpacing.mediumVerticalSpacing,
            ],
            AuthorTile(
              author: authorName,
              authorImage: authorImage,
            ),
            Text(
              DateFormat('MMMM d, yyyy').format(published),
              style: BlogTextStyles.subtitleTextStyle.copyWith(
                color: Theme.of(context).colorScheme.secondary,
                fontStyle: FontStyle.italic,
              ),
            ),
            Text(
              title,
              style: BlogTextStyles.headerTextStyle.copyWith(
                color: theme.colorScheme.primary,
              ),
            ),
            Html(
              data: body,
              style: styles(theme),
              onLinkTap: onLinkTap,
            ),
          ],
        ),
      ),
    );
  }
}

/// A function that returns a [Map] of styles to be defined for
/// the HTML body.
Map<String, Style> styles(ThemeData theme) {
  Style style({
    Color? backgroundColor,
    Color? color,
    TextStyle? textStyle,
    HtmlPaddings? padding,
  }) {
    return Style(
      backgroundColor: backgroundColor,
      color: color,
      fontSize:
          textStyle?.fontSize != null ? FontSize(textStyle!.fontSize!) : null,
      fontStyle: textStyle?.fontStyle,
      fontWeight: textStyle?.fontWeight,
      fontFamily: textStyle?.fontFamily,
      padding: padding,
    );
  }

  return {
    'a': style(
      color: theme.colorScheme.secondary,
      textStyle: BlogTextStyles.detailBodyTextStyle,
    ),
    'code': style(
      backgroundColor: Colors.transparent,
      color: theme.colorScheme.primary,
      textStyle: GoogleFonts.robotoMono(),
    ),
    'div': style(color: theme.colorScheme.primary),
    'figcaption': style(
      color: theme.colorScheme.primary,
      textStyle: BlogTextStyles.footerTextStyle,
    ),
    'h1': style(
      color: theme.colorScheme.primary,
      textStyle: BlogTextStyles.headerTextStyle,
    ),
    'h2': style(
      color: theme.colorScheme.primary,
      textStyle: BlogTextStyles.headerSubtitleTextStyle,
    ),
    'h3': style(
      color: theme.colorScheme.primary,
      textStyle: BlogTextStyles.cardTitle,
    ),
    'p': style(
      color: theme.colorScheme.primary,
      textStyle: BlogTextStyles.detailBodyTextStyle,
    ),
    'pre': style(
      backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
      padding: HtmlPaddings.all(16),
    ),
  };
}


Note the use of ThemeData to construct Style rules for commonly-used HTML packages. Passing styles this way ensures that the html content rendered matches theme data and supports any light/dark compatibility that the styles support. If you add any other HTML tags to your posts in Butter, they will need to be added here as well. 

The App

Alright, by now you should have an API client, a repository, and a UI package. Now we can turn to making the app.

First off, add go_router as a dependency to the root app, as we will use it for navigation. In the lib directory, create a folder titled router and add router.dart. Add the following route builder:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

GoRouter createRouter() {
  return GoRouter(
    routes: [
      StatefulShellRoute.indexedStack(
        builder: (context, state, navigationShell) => Scaffold(
          body: navigationShell,
        ),
        branches: [
          StatefulShellBranch(
            routes: [], // this will be populated when you make screens
          ),
        ],
      ),
    ],
  );
}


Next, go to app/view/app.dart and add the following content to provide the repository, theme, and router content:

import 'package:blog_repository/blog_repository.dart';
import 'package:blog_ui/blog_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:personal_blog_flutter/l10n/l10n.dart';
import 'package:personal_blog_flutter/router/router.dart';

class App extends StatefulWidget {
  const App({
    required this.blogRepository,
    this.router,
    super.key,
  });

  final BlogRepository blogRepository;
  final GoRouter? router;

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  @override
  Widget build(BuildContext context) {
    return MultiRepositoryProvider(
      providers: [
        RepositoryProvider.value(value: widget.blogRepository),
      ],
      child: MaterialApp.router(
        routerConfig: widget.router ?? createRouter(),
        theme: BlogTheme.lightThemeData,
        darkTheme: BlogTheme.darkThemeData,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
      ),
    );
  }
}


Alright, now it's time to make some screens! In the lib directory, create a folder named blog_overview with the following content (NB: use the VS Code bloc extension for quicker generation of the bloc files):

blog_overview
  bloc
    blog_overview_bloc.dart
    blog_overview_event.dart
    blog_overview_state.dart
  view
    blog_overview_page.dart

Fill the bloc event and bloc state files, respectively, with the following:

part of 'blog_overview_bloc.dart';

@immutable
sealed class BlogOverviewEvent extends Equatable {
  const BlogOverviewEvent();

  @override
  List<Object> get props => [];
}

final class BlogOverviewPostsRequested extends BlogOverviewEvent {
  const BlogOverviewPostsRequested();
}

part of 'blog_overview_bloc.dart';

@immutable
sealed class BlogOverviewState extends Equatable {
  @override
  List<Object> get props => [];
}

final class BlogOverviewInitial extends BlogOverviewState {}

final class BlogOverviewLoading extends BlogOverviewState {}

final class BlogOverviewFailure extends BlogOverviewState {
  BlogOverviewFailure({required this.error});
  final Object error;

  @override
  List<Object> get props => [error];
}

final class BlogOverviewLoaded extends BlogOverviewState {
  BlogOverviewLoaded({required this.previews});
  final List<BlogPreview> previews;

  @override
  List<Object> get props => [previews];
}

And add this to the bloc:

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:blog_api_client/blog_api_client.dart';
import 'package:blog_models/blog_models.dart';
import 'package:blog_repository/blog_repository.dart';
import 'package:equatable/equatable.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';

part 'blog_overview_event.dart';
part 'blog_overview_state.dart';

class BlogOverviewBloc extends Bloc<BlogOverviewEvent, BlogOverviewState> {
  BlogOverviewBloc({required BlogRepository blogRepository})
      : _blogRepository = blogRepository,
        super(BlogOverviewInitial()) {
    on<BlogOverviewPostsRequested>(_onBlogOverviewPostsRequested);
  }

  final BlogRepository _blogRepository;

  FutureOr<void> _onBlogOverviewPostsRequested(
    BlogOverviewPostsRequested event,
    Emitter<BlogOverviewState> emit,
  ) async {
    emit(BlogOverviewLoading());
    try {
      final previews = await _blogRepository.getBlogPreviews();
      emit(BlogOverviewLoaded(previews: previews));
    } on Exception catch (e) {
      if (e is BlogApiClientFailure) {
        emit(BlogOverviewFailure(error: e.body));
        return;
      }
      emit(BlogOverviewFailure(error: e.toString()));
    }
  }
}

Then add content to blog_overview_page.dart:

import 'package:blog_models/blog_models.dart';
import 'package:blog_repository/blog_repository.dart';
import 'package:blog_ui/blog_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:personal_blog_flutter/blog_overview/bloc/blog_overview_bloc.dart';

class BlogOverviewPage extends StatelessWidget {
  const BlogOverviewPage({super.key});

  factory BlogOverviewPage.routeBuilder(
    _,
    __,
  ) =>
      const BlogOverviewPage(
        key: Key('blog_overview_page'),
      );

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) =>
          BlogOverviewBloc(blogRepository: context.read<BlogRepository>())
            ..add(const BlogOverviewPostsRequested()),
      child: const BlogOverview(),
    );
  }
}

class BlogOverview extends StatelessWidget {
  const BlogOverview({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<BlogOverviewBloc, BlogOverviewState>(
      builder: (context, state) => switch (state) {
        BlogOverviewInitial() || BlogOverviewLoading() => const Center(
            child: CircularProgressIndicator(),
          ),
        BlogOverviewFailure(error: final error) => Center(
            child: Container(
              color: Theme.of(context).colorScheme.error,
              padding: BlogSpacing.allPadding,
              child: Text(
                error.toString(),
                style: BlogTextStyles.errorTextStyle.copyWith(
                  color: Theme.of(context).colorScheme.onError,
                ),
              ),
            ),
          ),
        BlogOverviewLoaded(previews: final previews) => _BlogOverviewContent(
            previews: previews,
          )
      },
    );
  }
}

class _BlogOverviewContent extends StatelessWidget {
  const _BlogOverviewContent({
    required this.previews,
  });

  final List<BlogPreview> previews;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: BlogSpacing.topMargin,
        child: Column(
          children: [
            Expanded(
              child: Padding(
                padding: BlogSpacing.horizontalPadding,
                child: ConstrainedBox(
                  constraints: const BoxConstraints(maxWidth: 1200),
                  child: ListView.builder(
                    itemCount: previews.length,
                    itemBuilder: (context, index) {
                      final preview = previews[index];
                      return BlogCard(
                        title: preview.title,
                        subtitle: preview.description,
                        published: preview.published,
                        imageUrl: preview.image,
                        onTap: () {
                          // navigation to detail page will go here
                        },
                      );
                    },
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Now head over to lib/router and add the following to the StatefulShellBranch routes to set the overview page as the default home page:

GoRoute(
  path: '/',
  pageBuilder: (context, state) => NoTransitionPage(
    child: BlogOverviewPage.routeBuilder(context, state),
  ),
)

Now, everything is set up for your home page to render a list of all of your blog posts, fetched from Butter CMS. If you run the app (don't forget to start the backend first!), you should see UI. Your themes may vary and your content will depend on your own Butter account, but you should see a BlogCard that looks something like this:



Now let's make a detail page that we can navigate to by clicking on the card above. Create a folder in lib titled blog_detail and add the following content:

blog_detail
  bloc
    blog_detail_bloc.dart
    blog_detail_event.dart
    blog_detail_state.dart
  view
    blog_detail_page.dart

The bloc is nearly the same as it is for blog_overview, so I won't add all the code here (you can check it out at the sample project if you're curious). The only major difference here is that the bloc should call blogRepository.getBlogDetail, passing the unique slug of the blog to be fetched. If you want to be able to launch URLs in your blog content, you can also add an event to handle this in the bloc using url_launcher:

  FutureOr<void> _onBlogLinkClicked(
    BlogLinkClicked event,
    Emitter<BlogDetailState> emit,
  ) async {
    try {
      if (await canLaunchUrlString(event.url)) {
        await launchUrlString(event.url, mode: LaunchMode.externalApplication);
      }
    } on Exception catch (e) {
      emit(BlogDetailFailure(error: e.toString()));
    }
  }


If you do implement link clicking, note that you can wrap url_launcher in a custom class and inject it into your bloc constructor will make for easier testing:

// Wrapper around url_launcher package to allow for mocking and testing.
import 'package:url_launcher/url_launcher_string.dart';

class UrlLauncher {
  Future<bool> validateUrl({required String url}) async =>
      canLaunchUrlString(url);

  Future<void> launchUrl({required String url}) async =>
      launchUrlString(url, mode: LaunchMode.externalApplication);
}

  BlogDetailBloc({
    required BlogRepository blogRepository,
    required String slug,
    UrlLauncher? urlLauncher,
  })  : _blogRepository = blogRepository,
        _slug = slug,
        _urlLauncher = urlLauncher ?? UrlLauncher(),


The content of blog_detail_page.dart is as follows:

import 'package:blog_repository/blog_repository.dart';
import 'package:blog_ui/blog_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:personal_blog_flutter/blog_detail/bloc/blog_detail_bloc.dart';

class BlogDetailPage extends StatelessWidget {
  const BlogDetailPage({
    required this.slug,
    super.key,
  });

  factory BlogDetailPage.routeBuilder(_, GoRouterState state) => BlogDetailPage(
        slug: state.matchedLocation.substring(1),
        key: const Key('blog_detail_page'),
      );

  final String slug;

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => BlogDetailBloc(
        blogRepository: context.read<BlogRepository>(),
        slug: slug,
      )..add(const BlogDetailRequested()),
      child: const BlogDetailView(),
    );
  }
}

class BlogDetailView extends StatelessWidget {
  const BlogDetailView({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<BlogDetailBloc, BlogDetailState>(
      builder: (context, state) => switch (state) {
        BlogDetailInitial() || BlogDetailLoading() => const Center(
            child: CircularProgressIndicator(),
          ),
        BlogDetailFailure(error: final error) => Center(
            child: Container(
              color: Theme.of(context).colorScheme.error,
              padding: BlogSpacing.allPadding,
              child: Text(
                error.toString(),
                style: BlogTextStyles.errorTextStyle.copyWith(
                  color: Theme.of(context).colorScheme.onError,
                ),
              ),
            ),
          ),
        BlogDetailLoaded(detail: final detail) => BlogDetailContent(
            authorName: '${detail.author.firstName} ${detail.author.lastName}',
            body: detail.body,
            published: detail.published,
            slug: detail.slug,
            title: detail.title,
            authorImage: detail.author.profileImage,
            featuredImage: detail.featuredImage,
            onLinkTap: (url, attributes, element) => context
                .read<BlogDetailBloc>()
                .add(BlogLinkClicked(url: url ?? '')),
          )
      },
    );
  }
}

Add another route in router.dart:

GoRoute(
  path: '/:slug',
  pageBuilder: (context, state) => NoTransitionPage(
    child: BlogDetailPage.routeBuilder(context, state),
  ),
)

And navigate to the detail page when clicking an item on the overview page:

BlogCard(
  title: preview.title,
  subtitle: preview.description,
  published: preview.published,
  imageUrl: preview.image,
  onTap: () {
    context.go('/${preview.slug}');
  },
)

Now, when you run the app and click on a blog card on the overview page, you should navigate to the detail page:



Congratulations! You now have a web app that displays content fetched from your API. Up next, adding a pipeline and deployment!