Search code examples
flutterblocfreezed

How to represent shared state with freezed without casting


I'm using the freezed package to generate state objects which are consumed by the bloc library.

I like the ability to define union classes for a widget's state so that I can express the different and often disjoint states that a widget has. For example:

@freezed
class ResultsReportState with _$ResultsReportState {
  const factory ResultsReportState.loading() = ResultsReportLoading;

  const factory ResultsReportState.success({
    required ReportViewViewModel report,
  }) = ResultsReportSuccess;

  const factory ResultsReportState.refreshing({
    required ReportViewViewModel report,
  }) = ResultsReportRefreshing;

  const factory ResultsReportState.error() = ResultsReportError;
}

In the snippet above, my intent is to not show any data when there was an error or the widget is loading, but I do still want to show data if it successfully loads or if the user is refreshing the widget. So the ResultsReportSuccess and ResultsReportRefreshing states have a shared state which is ReportViewViewModel. However, I have no ability to access those shared properties even after performing a type check as suggested here.

For example, this does not work without an explicit type-cast:

class ResultsReport extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ResultsReportBloc, ResultsReportState>(
      builder: (context, state) {
        if (state is ResultsReportSuccess || state is ResultsReportRefreshing) {
          return SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                var serviceCategory = state.report.serviceCategories[index];
                return ServiceCategoryBlock(
                  viewModel: serviceCategory,
                );
              },
              childCount: state.report.serviceCategories.length,
            ),
          );
        } else if (state is ResultsReportLoading) {
          return ResultsScreenLoadingSkeleton();
        } else {
          return SliverFillRemaining(
            child: ErrorStateContent(
              onErrorRetry: () {
                context
                    .read<ResultsReportBloc>()
                    .add(ResultsReportEvent.retryButtonTapped());
              },
            ),
          );
        }
      },
    );
  }
}

But there is nothing for me to explicitly type-cast to since it could be either type. So, I tried this approach instead which introduces an interface that I can refer to:

part of 'results_report_bloc.dart';

abstract class ReportPopulated {
  ReportViewViewModel get report;
}

@freezed
class ResultsReportState with _$ResultsReportState {
  const factory ResultsReportState.loading() = ResultsReportLoading;

  @Implements<ReportPopulated>()
  const factory ResultsReportState.success({
    required ReportViewViewModel report,
  }) = ResultsReportSuccess;

  @Implements<ReportPopulated>()
  const factory ResultsReportState.refreshing({
    required ReportViewViewModel report,
  }) = ResultsReportRefreshing;

  const factory ResultsReportState.error() = ResultsReportError;
}
class ResultsReport extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ResultsReportBloc, ResultsReportState>(
      builder: (context, state) {
        if (state is ReportPopulated) {
          return SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                var serviceCategory = state.report.serviceCategories[index];
                return ServiceCategoryBlock(
                  viewModel: serviceCategory,
                );
              },
              childCount: state.report.serviceCategories.length,
            ),
          );
        } else if (state is ResultsReportLoading) {
          return ResultsScreenLoadingSkeleton();
        } else {
          return SliverFillRemaining(
            child: ErrorStateContent(
              onErrorRetry: () {
                context
                    .read<ResultsReportBloc>()
                    .add(ResultsReportEvent.retryButtonTapped());
              },
            ),
          );
        }
      },
    );
  }
}

But this also requires a type-cast. So, I could do this:

class ResultsReport extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ResultsReportBloc, ResultsReportState>(
      builder: (context, state) {
        if (state is ReportPopulated) {
          ReportPopulated currentState = state as ReportPopulated;
          return SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                var serviceCategory = currentState.report.serviceCategories[index];
                return ServiceCategoryBlock(
                  viewModel: serviceCategory,
                );
              },
              childCount: currentState.report.serviceCategories.length,
            ),
          );
        } else if (state is ResultsReportLoading) {
          return ResultsScreenLoadingSkeleton();
        } else {
          return SliverFillRemaining(
            child: ErrorStateContent(
              onErrorRetry: () {
                context
                    .read<ResultsReportBloc>()
                    .add(ResultsReportEvent.retryButtonTapped());
              },
            ),
          );
        }
      },
    );
  }
}

But I'm left wondering why the type-cast is necessary, as it just feels cumbersome. Any insight someone can provide on how to accomplish my goal of shared state differently is certainly welcomed.


Solution

  • I think the problem you are facing could be related to Dart type promotion that does not always work as you could expect. It is thoroughly explained here.

    However, how I do handle this with freezed is by using the generated union methods. When rendering the UI, you could use them like this:

    class ResultsReport extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return BlocBuilder<ResultsReportBloc, ResultsReportState>(
          builder: (context, state) => state.maybeWhen(
            loading: () => ResultsScreenLoadingSkeleton(),
            success: (report) => SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) {
                  var serviceCategory = report.serviceCategories[index];
                  return ServiceCategoryBlock(
                    viewModel: serviceCategory,
                  );
                },
                childCount: report.serviceCategories.length,
              ),
            ),
            refreshing: (report) => SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) {
                  var serviceCategory = report.serviceCategories[index];
                  return ServiceCategoryBlock(
                    viewModel: serviceCategory,
                  );
                },
                childCount: report.serviceCategories.length,
              ),
            ),
            error: () => SliverFillRemaining(
              child: ErrorStateContent(
                onErrorRetry: () {
                  context
                      .read<ResultsReportBloc>()
                      .add(ResultsReportEvent.retryButtonTapped());
                },
              ),
            ),
          ),
        );
      }
    }
    

    Notice that success and refreshing states' code is duplicated, hence you should probably extract it to a separate Widget.