response_builder 0.0.2-beta1 copy "response_builder: ^0.0.2-beta1" to clipboard
response_builder: ^0.0.2-beta1 copied to clipboard

outdated

A new Flutter package project.

response_builder #

Star this Repo Pub Package

Why create this library? #

FutureBuilder StreamBuilder are the foundation to consume any data that loaded from network or persistent storage. FutureBuilder StreamBuilder are designed to be robust, flexible but it is not designed to be convenient. In fact handling with AsyncValueSnapshot in the callback is actually kind of complicated and error-prone, especially when you needs busy state, initial value, or switcing to observe new future/stream from the old ones. Any mistake in the callback could cause bug in UI.

To reduce the complexity and avoid repetivie work to implement AsyncWidgetBuilder a thousand times, this libary introduce some mixins, which help developer to consuem data from future, stream, value listanble or other kind of observable sources in a nice and easy way.

What response_builder can do? #

It enables flutter developer to implement following features with minimum effort:

  • Loading Screen: Show a loading view when data not comming back
  • Error Screen: Show an error screen when something goes wrong
  • Empty Screen: Some something useful when API returns an empty list (As ListView and others don't support to render empty collection)
  • Refresh/Retry: Fire the same request again when something goes wrong or just want to refresh the data
  • Optimal update: update the UI optimally first, and refresh UI again when API returns.

Consume data from Future/Stream #

To consume data from Future/Stream with response_builder library is very easy:

  1. Create a stateless/stateful widget
  2. Include BuildAsyncResult<T> mixin, T is the the data type contained in Stream/Future
  3. Implement buildData, which is used to render the UI when the data is successfully loaded from Stream, Future.
  4. Calling buildFuture/buildStream in widget's build method, to trigger wire the data source to BuildAsyncResult mixin.
import 'package:response_builder/response_builder.dart';

class MyWidget extends StatelessWidget with BuildAsyncResult<String> {
  final dynamic dataSource; // Stream<String> or Future<String>

  MyWidget(this.dataSource)
      : assert(dataSource != null),
        assert(dataSource is Stream<String> || dataSource is Stream<String>);

  @override
  Widget build(BuildContext context) {
    if (dataSource is Stream)
      // Calling buildStream is dataSource is Stream
      return buildStream(dataSource);
    else
      // Calling buildFuture is dataSource is Future
      return buildFuture(dataSource);
  }

  @override
  Widget buildData(BuildContext context, String data) {
    // Implement buildData contract to render UI when data is successfully fetched
    return Center(
      child: Text(data),
    );
  }
}

With the code above, you will get:

  • Render a loading screen automatically before the data is ready
  • Render a error screen automatically before if data source yields error

Customize Error Screen / Loading Screen by overriding #

You might want to customize the error screen / loading screen, which is also very easy:

  • Override buildWaiting to customize the loading screen.
  • Override buildWaiting to customize the busy screen.
import 'package:response_builder/response_builder.dart';

class MyWidget extends StatelessWidget with BuildAsyncResult<String> {
  final dynamic dataSource; // Stream<String> or Future<String>

  MyWidget(this.dataSource)
      : assert(dataSource != null),
        assert(dataSource is Stream<String> || dataSource is Stream<String>);

  @override
  Widget build(BuildContext context) {
    if (dataSource is Stream)
      return buildStream(dataSource);
    else
      return buildFuture(dataSource);
  }

  @override
  Widget buildWaiting(BuildContext context) {
    // This is the default Loading Screen implementation. You can customize it as your need.
    return Center(
      child: CircularProgressIndicator(value: null),
    );
  }

  @override
  Widget buildError(BuildContext context, Object error) {
    // This is the default Error Screen implementation, you can customize it as your need.
    final errorColor = Theme.of(context).errorColor;

    return Center(
      child: Row(children: [
        Icon(Icons.error_outline, color: errorColor),
        Text(error.toString(), style: TextStyle(color: errorColor)),
      ]),
    );
  }

  @override
  Widget buildData(BuildContext context, String data) {
    return Center(
      child: Text(data),
    );
  }
}

Customize default Error Screen / Loading Screen build actions #

Sometimes the default error screen/loading screen might not suits your app, such as you're using CuptertinoApp instead of MaterialApp, but override buildWaiting and buildError in every widget uses BuildAsyncResult could be a tedious and heavy task. Luckily, you actually don't need to do that.

BuildAsyncResult uses DefaultBuildActions to build the error screen/loading screen if buildWaiting or buildError is not override. And DefaultBuildActions allow to you register your own error builder and waiting builder.

DefaultBuildActions.registerDefaultWaitingBuilder((context) {
  // Build Cupertino Style Loading Screen

  return Center(
    child: CupertinoActivityIndicator(),
  );
});

DefaultBuildActions.registerDefaultErrorBuilder((context, error) {
  // Build Cupertino Style Error Screen
  final errorColor = CupertinoColors.systemRed;

  return Center(
    child: Row(children: [
      Icon(CupertinoIcons.xmark_circle, color: errorColor),
      Text(error.toString(), style: TextStyle(color: errorColor)),
    ]),
  );
});

HINT: Register default builder with DefaultBuildActions will only impact those widgets uses BuildAsyncResult without overriding buildError or buildWaiting. Customized override will be respected

Handle empty data #

Sometimes our API returns successfully without error, but it gives empty result, such as user doing a search with typos in keywords which leads to nothing. And also if you render the UI with ListView or other collection widgets, which unfortunately doesn't support to build empty list.

response_builder provided WithEmptyData<T> mixin to tackle this issue, what's more is WithEmptyData<T> is aware of the contracts from BuildAsyncResult<T> or other builder mixins, so it just work together automatically without any additional effort.

class MyListWidget extends StatelessWidget with BuildAsyncResult<List<String>>, WithEmptyData<List<String>> {
  final Future<List<String>> future;

  const MyListWidget({Key key, this.future}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return buildFuture(future);
  }

  // Instead of implement buildData, you should implement buildContent as minimal implementation
  @override
  Widget buildContent(BuildContext context, List<String> content) {
    // Implement contract from WithEmptyData to build not empty content

    // content will never be empty
    assert(content.isNotEmpty);

    return ListView.builder(
      itemCount: content.length,
      itemBuilder: (_, index) => Text(content[index]),
    );
  }
}

Customize Empty Screen for WithEmptyData #

By Default, WithEmptyData renders an empty Container when empty data is received, so it looks like an blank screen from user's perspective, while ListView would complain if you feed it with an empty list.

But sometimes, you might also want to render a more meaningful empty screen rather than just a blank screen. Then you can override buildEmpty method.

class MyListWidget extends StatelessWidget with BuildAsyncResult<List<String>>, WithEmptyData<List<String>> {
  final Future<List<String>> future;

  const MyListWidget({Key key, this.future}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return buildFuture(future);
  }

  // Instead of implement buildData, you should implement buildContent as minimal implementation
  @override
  Widget buildContent(BuildContext context, List<String> content) {
    // Implement contract from WithEmptyData to build not empty content

    // content will never be empty
    assert(content.isNotEmpty);

    return ListView.builder(
      itemCount: content.length,
      itemBuilder: (_, index) => Text(content[index]),
    );
  }

  @override
  Widget buildEmpty(BuildContext context, List emptyContent) {
    // Override Empty Screen
    return Center(
      child: Text("There is nothing here"),
    );
  }
}

Handle empty data model in WithEmptyData #

By default WithEmptyData is smart enough to understand the common data types:

  • null is always treated as empty content
  • Anything implements Iterable
    • Dart built-in collection types, such as List or Set
    • 3rd party collection types, such as BuiltList, KtList
  • Map, probably parsed from a json object or so.

But if you're using a customized data object, which isn't a Map or Iterable, or you want to do have your own implementation logic, then you will need to override checkIsDataEmpty method, or you might get an UnsupportedError.

class MyListWidget extends StatelessWidget with BuildAsyncResult<List<String>>, WithEmptyData<List<String>> {
  final Future<List<String>> future;

  const MyListWidget({Key key, this.future}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return buildFuture(future);
  }

  // Instead of implement buildData, you should implement buildContent as minimal implementation
  @override
  Widget buildContent(BuildContext context, List<String> content) {
    // Implement contract from WithEmptyData to build not empty content

    // content will never be empty
    assert(content.isNotEmpty);

    return ListView.builder(
      itemCount: content.length,
      itemBuilder: (_, index) => Text(content[index]),
    );
  }

  @override
  bool checkIsDataEmpty(List<String> data) {
    // Only count non-empty string
    return data.where((e) => e.isNotEmpty).isEmpty;
  }
}

Load data asynchronously with Request #

Loading data from network or data base is a extremely common behaviour of majority of the apps. Unfortunately Future or Stream is kind of too low level to implement app's common requirements.

Request is a production-ready abstraction of common behavior that loads data from either an API or making a query to database.

Request provides some useful API which enable developer to implement following commonly-seen features easily:

  • Retry when error API call fails
  • Refresh data automatically without being noticed by user
  • Optimal update based user's action first, and refresh UI again when API returns.

buildRequest from BuildAsyncResult allow developer to consume the data from Request with no difference from buildFuture or buildStream.

Here is an example request that make a search of given keywords:

class MySearchRequest extends Request<List<SearchItem>> {
  final String keywords;

  MySearchRequest(this.keywords);

  Future<List<SearchItem>> load() async {
    final response = await searchApi.search(keywords: keywords);

    if (response.statusCode != 200) {
      throw NetworkException("Failed to execute search, please retry");
    }

    return response.parseBody();
  }
}

Here is the Widget to render the search result:

class SearchResultView extends StatelessWidget with BuildAsyncResult<List<SearchItem>>, WithEmptyData<List<SearchItem>> {
  final MySearchRequest request;

  SearchResultView(this.request);

  @override
  Widget build(BuildContext context) {
    // use method from BuildAsyncResult to render request
    return buildRequest(request);
  }

  @override
  Widget buildError(BuildContext context, Object error) {
    return Center(
      child: Row(children: [
        Text(error.toString()),
        TextButton(
          child: Text("Retry"),
          onPressed: () => request.reload(), // Retry to do search again
        )
      ]),
    );
  }

  @override
  Widget buildContent(BuildContext context, List<SearchItem> content) {
    return ListView.builder(
      itemCount: content.length,
      itemBuilder: (context, index) => SearchItemView(content[index]),
    );
  }
}

Use Request out side of widget building #

Request is naturally a good place to implement data loading business logic. But it can do more thant that, Request provides a bunch of APIs allow developer to manage its value, so it can a good place to encapsulate business logic that related to the data that request holds. And any updates to request's data would be rendered properly, if it is consumed by BuildAsyncResult

Because Request is highly optimized for the scenario that loading data asynchronously, such as calling an API or querying database, in those particular use-cases, Request could be good replacement of

class MySearchRequest extends Request<List<SearchItem>> {
  final String keywords;

  MySearchRequest(this.keywords);

  Future<List<SearchItem>> load() async {
    final response = await searchApi.search(keywords: keywords);

    if (response.statusCode != 200) {
      throw NetworkException("Failed to execute search, please retry");
    }

    return response.parseBody();
  }

  Future saveSearchResult(SearchResultFile file) async {
    if (this.hasData) {
      // write search result to file if it exists
      await file.writes(currentData);
    } else if (this.hasError) {
      // writes error to file with writeError method
      await file.writeError(currentError);
    }
    // Skip if no data is loaded yet.
  }

  Future loadSearchResult(SearchResultFile file) async {
    Future<List<SearchItem>> Function() loadAction = file.read;

    // update request with data or error loaded from the file
    await execute(loadAction);
  }

  void clearSearchResult() {
    // Set value of request synchronously
    putValue([]);
  }

  void trimResult(int limit) {
    // Update result based on current data;
    updateValue((current) => currnt.take(limit).toList());
  }

  Future appendFromFile(SearchResultFile file) {
    // Update result based on current data asynchronously
    return updateValueAsync((current) async => current + await file.read());
  }
}

Handle 2-state data with ResultStore #

Flutter provided ValueListenable as synchronous observable data source. ValueListenable is 1-state, which cannot handle error, which is occasionally needed.

response_builder provides ResultStore, which could holds either data or error.

Example #

Suppose FormData model holds a list of fields value. Field value is always string, but its could be valid or invalid.

class FormData {
  final Map<String, ResultStore<String>> fields;

  FormData(Map<String, String> initialValues)
      : fields = Map.fromIterables(
          initialValues.keys, // field keys
          initialValues.values.map(
            // wrap initial with ResultStore
            (initialValue) => ResultStore(initialValue),
          ),
        );

  void invalidField(String fieldName) {
    // String can be thrown
    // Store would treat thrown string as error
    fields[fieldName].updateValue((current) => throw current);
  }

  void validField(String fieldName) {
    // Fix error only execute when store holds error
    // the returned value is used as value
    fields[fieldName].fixError((error) => error);
  }
}

Build ResultStore with BuildResultListenable #

ResultStore can be listened with BuildResultListenable, which shares the similar contract as BuildAsyncResult

Example #

class FormFieldView extends StatelessWidget with BuildResultListenable<String> {
  final String fieldName;
  final ResultStore<String> fieldStore;

  const FormFieldView({Key key, this.fieldName, this.fieldStore}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(fieldName),
        buildStore(fieldStore),
      ],
    );
  }

  @override
  Widget buildData(BuildContext context, String data) {
    return Text(data);
  }

  @override
  Widget buildError(BuildContext context, Object error) {
    final errorColor = Theme.of(context).errorColor;

    final badData = error as String;

    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8.0),
          child: Icon(Icons.error_outline, color: errorColor),
        ),
        Text(badData, style: TextStyle(color: errorColor)),
      ],
    );
  }
}

HINT WithEmptyData can be used withBuildResultListenable to handle empty content too.

Consume ValueListenable with BuildValueListenable #

Similar to BuildResultListenable, built-in ValueListenable can be consumed with BuildValueListenable with compatible manner.

HINT WithEmptyData can be used withBuildValueListenable to handle empty content too.

SingletonRegistry #

WIP

License #

The MIT License (MIT)

1
likes
0
points
6
downloads

Publisher

verified publisherpub.timnew.me

Weekly Downloads

A new Flutter package project.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

async, flutter, rxdart

More

Packages that depend on response_builder