codenic_bloc_use_case 0.1.0 copy "codenic_bloc_use_case: ^0.1.0" to clipboard
codenic_bloc_use_case: ^0.1.0 copied to clipboard

outdated

A suite of abstract cubits to reduce boilerplate code and construct app use cases.

GitHub Workflow Status CodeCov style: very good analysis GitHub GitHub code size in bytes Pub Version

Apply Clean Architecture with a suite of abstract use cases based on Bloc Cubits.

This is an extension of the bloc package.

This uses the dartz package and extensively makes use of its Either monad for managing union types.

Inspired by Reso Coder's Flutter TDD Clean Architecture.

Codenic Bloc Use Case

Features #

Use this package in your app to:

  • Easily create app use cases by abstracting from available bloc templates for binary execution (runner), pagination and data streaming.
  • Receive either left (failed) or right (success) values from your use cases with the help of the dartz package.
  • Standardize the way you use Blocs.

Usage #

This section contains detailed information for the following topics:

The Runner Use Case #

A simple use case that executes a specific action with the given parameter arguments by calling Runner.run() which either returns a Left (failed) or Right (success) response.

The Runner State Flow

Creating a Runner #

For more info, see the Runner docs.

/// A runner that counts the quantity of each given fruit.
class CountFruit extends Runner<CountFruitParams, Failure, CountFruitResult> {
 
  /// The callback executed when [run] gets called
  @override
  Future<Either<Failure, CountFruitResult>> onCall(
    CountFruitParams params,
  ) async {
    if (params.fruits.isEmpty) {
      // When the given fruits is empty, then a `Left` value is returned
      return const Left(Failure('There are no fruits to count'));
    }
  
    final fruitCount = <String, int>{};
  
    for (final fruit in params.fruits) {
      fruitCount[fruit] = (fruitCount[fruit] ?? 0) + 1;
    }
  
    // Returns a `Right` value containing the fruit count
    final result = CountFruitResult(fruitCount);
    return Right(result);
  }
}
 
/// A special parameter for [CountFruit] containing all the available fruits to
/// count.
class CountFruitParams {
  const CountFruitParams(this.fruits);
  
  final List<String> fruits;
}
 
/// The `Left` value for [CountFruit].
class Failure {
  const Failure(this.message);
  
  final String message;
  
  @override
  String toString() => 'Failure: $message';
}
 
/// The `Right` value for [CountFruit] which contains the count for each fruit.
class CountFruitResult {
  const CountFruitResult(this.fruitCount);
  
  final Map<String, int> fruitCount;
  
  @override
  String toString() => '$fruitCount';
}

Using a Runner #

void main() {
  // Initialize a `CountFruit` runner instance
  final countFruit = CountFruit();
  
  // Execute the runner
  await countFruit.run(
    params: const CountFruitParams(['Apple', 'Orange', 'Apple']),
  );
  
  // The last `Left` value returned when calling `run()`
  print(countFruit.leftValue);
  
  // The last `Right` value returned when calling `run()`
  print(countFruit.rightValue);
  
  // The recent value returned when calling `run()`. This may either be a
  // `Left` object containing the `leftValue` or a `Right` object containing
  // the `rightValue`
  print(countFruit.value);
  
  // To set all these values back to `null`, call `reset()`
}

The Paginator Use Case #

A use case for fetching a list of items in a paginated manner.

A paginator has two available methods for loading pages.

To start paginating, Paginator.loadFirstPage() must initially be called. This accepts some given parameter arguments for loading the first page. If the loading fails, then a Left value is returned. Otherwise, if the loading succeeds, a Right value instance of PageResult and a PageResultListItem will be returned.

The Paginator State Flow for Loading the First Page

The PageResult contains all the items available in the fetched page as well as a token for loading the next one, whereas a PageResultListItem is a collection of all page results and their aggregated items.

To load the next page, call Paginator.loadNextPage(). This uses the old given parameter arguments and page result to load the upcoming page. If the last page has been loaded (i.e. the last page result's token is null), then calling this method does nothing.

Calling loadNextPage() without initially loading the first page will throw a StateError.

Use the Paginator.currentPageIndex to determine if the first page has been loaded. If it is equal to -1, then the first page has not been loaded yet.

The Paginator State Flow for Loading the Next Page

Creating a Paginator #

For more info, see the Paginator docs.

/// A paginator that accepts a list of fruits then returns them in a paginated
/// manner.
class PaginateFruits extends Paginator<PaginateFruitsParams, Failure,
   PageResult<String>, String> {
 
 /// The callback executed when [loadFirstPage] or [loadNextPage] gets called.
 /// Page loading occurs here.
 @override
 Future<Either<Failure, PageResult<String>>> onCall(
    PaginateFruitsParams params, [
    PageResult<String>? previousPageResult,
  ]) async {
    if (params.itemsPerPage < 1) {
      // When the items per page is less than 1, then a `Left` value is returned
      return const Left(Failure('Page item count must be greater than 0'));
    }
  
    final fruits = params.fruits;
    final itemsPerPage = params.itemsPerPage;
    final dynamic nextPageToken = previousPageResult?.nextPageToken;
  
    final nextFruitStartIndex =
        nextPageToken == null ? 0 : fruits.indexOf(nextPageToken as String) + 1;
  
    final newFruits =
        fruits.skip(nextFruitStartIndex).take(itemsPerPage);
    
    final newPageToken = newFruits.isNotEmpty ? newFruits.last : null;
  
    // Return a `Right` value containing the next page of fruits
    return Right(PageResult(newFruits, newPageToken));
  }
}
 
/// A special parameter for [PaginateFruits] containing all the available
/// fruits to paginate and the number of fruits per page.
class PaginateFruitsParams {
  const PaginateFruitsParams({required this.fruits, required this.itemsPerPage});
  
  final List<String> fruits;
  final int itemsPerPage;
}
 
/// The `Left` value for [PaginateFruits].
class Failure {
  const Failure(this.message);
  
  final String message;
  
  @override
  String toString() => 'Failure: $message';
}

Using a Paginator #

// Initialize a `PaginateFruits` paginator instance
final paginateFruits = PaginateFruits();
 
// Load the first page
await paginateFruits.loadFirstPage(
   params: const PaginateFruitsParams(
     fruits: ['Apple', 'Orange', 'Kiwi', 'Lime'],
     itemsPerPage: 2,
   ),
 );
 
// Load the next page
await paginateFruits.loadNextPage();
 
// The last `Left` value returned when calling `loadFirstPage()` or
// `loadNextPage()`
print(paginateFruits.leftValue);
 
// The last `Right` value instance of `PageResult` returned when calling
// `loadFirstPage()` or `loadNextPage()`
print(paginateFruits.rightValue);
 
// The recent value returned when calling `loadFirstPage()` or
// `loadNextPage()`. This may either be a `Left` object containing the
// `leftValue` or a `Right` object containing the `rightValue`
print(paginateFruits.value);
 
/// Contains all the page results and an aggregate of all their items
print(paginateFruits.pageResultItemList);
 
// The index of the last page loaded
print(paginateFruits.currentPageIndex);
 
// To set all these values back to `null`, call `reset()`

Creating a Custom Page Result #

If you want to have additional properties and behaviors in the PageResult, then consider extending it.

Since PageResult uses the EquatableMixin from the equatable package, don't forget to add your custom properties in props.

class PaginateFruitsResult extends PageResult<Fruit> {
  PaginateFruitsResult(
    this.remainingPages, 
    Iterable<Fruit> items, 
    dynamic nextPageToken,
  ) : super(items, nextPageToken);
  
  /// A custom property
  final int remainingPages;
  
  /// Add custom properties in props.
  @override
  List<Object?> get props => super.props..addAll([remainingPages]);
  
  @override
  String toString() => items.toString();
}

The Watcher Use Case #

A use case for watching a stream that emits a Left (error) or Right (data) event.

Call the Watcher.watch() to start creating and listening to a stream. This returns a Left value when the stream initialization fails. When the stream setup succeeds, a Right value instance of VerboseStream will be provided.

A VerboseStream contains the stream being listened to and an error converter which converts the error received by the stream to the specified Left error event.

The Watcher State Flow

Creating a Watcher #

For more info, see the Watcher docs.

/// A watcher for streaming fruits that goes inside the fruit basket.
class WatchFruitBasket extends Watcher<WatchFruitBasketParams, Failure,
   VerboseStream<Failure, FruitBasket>, Failure, FruitBasket> {
  StreamController<FruitBasket>? streamController;
  
  int? basketCapacity;
  List<String>? fruits;
  
  /// The callback triggered when [watch] is called. Use this to create a
  /// [VerboseStream] containing the target stream.
  @override
  Future<Either<Failure, VerboseStream<Failure, FruitBasket>>> onCall(
      WatchFruitBasketParams params,
  ) async {
    if (params.maxCapacity < 1) {
      // When the basket capacity is less than 1, then a left value is returned
      return const Left(Failure('Basket capacity must be greater than 0'));
    }
    
  
    basketCapacity = params.maxCapacity;
    fruits = [];
    await streamController?.close();
  
    streamController = StreamController<FruitBasket>();
  
    // Return a right value `VerboseStream` containing the stream that will be
    // listened to and its error converter
    return Right(
      VerboseStream(
        stream: streamController!.stream,
        errorConverter: (error, stackTrace) => Failure(error.toString()),
      ),
    );
  }
  
  
  void addFruits(List<String> newFruits) {
    if (fruits == null || basketCapacity == null || streamController == null) {
      return;
    }
  
    if (fruits!.length + newFruits.length <= basketCapacity!) {
      fruits!.addAll(newFruits);
      // Emit a right data event.
      streamController!.add(FruitBasket(fruits!));
    } else {
      // Emit a left data event.
      streamController!.addError(Exception('Fruit Basket is full'));
    }
  }
  
  Future<void> closeStream() async => streamController?.close();
  
  @override
  Future<void> close() {
    streamController?.close();
    return super.close();
  }
}
 
/// A special parameter for [WatchFruitBasket] for setting its max capacity.
class WatchFruitBasketParams {
  const WatchFruitBasketParams({required this.maxCapacity});
  
  final int maxCapacity;
}
 
/// The `Left` value for [WatchFruitBasket].
class Failure {
  const Failure(this.message);
  
  final String message;
  
  @override
  String toString() => 'Failure: $message';
}
 
/// The `Right` value for [WatchFruitBasket].
class FruitBasket {
  const FruitBasket(this.fruits);
  
  final List<String> fruits;
  
  @override
  String toString() => 'FruitBasket: $fruits';
}

Using a Watcher #

Future<void> watcher() async {
 
  // Initialize a `WatchFruitBasket` watcher instance
  final watchFruitBasket = WatchFruitBasket();
  
  // Start the stream
  await watchFruitBasket.watch(
    params: const WatchFruitBasketParams(maxCapacity: 4),
  );
  
  // Add fruits to emit a right event
  watchFruitBasket.addFruits(['Apple', 'Orange', 'Mango']);
  
  await Future<void>.delayed(Duration.zero);
  
  // The last left value returned when calling `watch()`
  print(watcher.leftValue);
  
  // The last right value instance of `VerboseStream` returned when calling
  // `watch()`
  print(watcher.rightValue);
  
  // The recent value returned when calling `watch()`. This may either be a
  // `Left` object containing the `leftValue` or a `Right` object containing
  // the `rightValue`
  print(watcher.value);
  
  // The last error event emitted by the stream created by calling
  // `watch()`
  print(watcher.leftEvent);
  
  // The last data event emitted by the stream created by calling
  // `watch()`
  print(watcher.rightEvent);
  
  // The recent value returned by the watch-created stream. This can either
  // reference the `leftEvent` or the `rightEvent`
  print(watcher.event);
  
  // To set all these values back to `null`, call `reset()`
}

Creating a Custom Verbose Stream #

A custom VerboseStream can be created if you want to add new properties or customize its behavior.

class CustomVerboseStream extends VerboseStream<Failure, List<String>> {
  CustomVerboseStream({
    required this.streamController,
    required Stream<List<String>> stream,
    required Failure Function(Object, StackTrace?) errorConverter,
  }) : super(
          stream: stream,
          errorConverter: errorConverter,
        );
  
  /// A custom property
  final StreamController streamController;
  
  @override
  StreamSubscription<List<String>> listen(
    void Function(List<String>) onData, {
    void Function(Failure)? onError,
    void Function()? onDone,
    bool? cancelOnError,
  }) {
    // Custom stream behavior
    return stream.listen((event) {
    });
  }
}

The Batch Runner Use Case #

The batch runner allows you to run multiple use cases at the same time or by batch.

First, a BatchRunner must be given a list of UseCaseFactory responsible for creating and calling a specific UseCase, and a constructor parameter fed to the use case factories for constructing the use cases.

A Runner, Paginator, and Watcher can be placed in a BatchRunner.

To start the batch run, call BatchRunner.batchRun. All use case factories in the first batch will be triggered with the given parameter arguments. If all of them are successful, then the next batch will push through followed by the next. If any of the use case fails, then the batch run will halt.

Call the BatchRunner.batchRun again to continue the batch run. By default, all successfully executed use cases from preceding calls will not be triggered. This behavior can be altered by creating a custom UseCaseFactory.

Every batch run call, regardless whether it succeeds or fails, returns a BatchRunResult containing all the failed leftUseCases and successful rightUseCases.

The Batch Runner State Flow

Creating A Batch Runner #

For more info, see the BatchRunner docs.

/// Fetches a meal by creating and executing the [FetchFruits] and
/// [FetchVeggies] use cases in the first batch, followed by [FetchGrains] in
/// the second batch.
class BatchFetchMeal extends BatchRunner<
    BatchFetchMealConstructorParams, BatchFetchMealCallParams> {
  BatchFetchMeal({
    required BatchFetchMealConstructorParams constructorParams,
  }) : super(
          useCaseConstructorParams: constructorParams,
          useCaseFactories: [
            // The first batch of use cases
            [
              UseCaseFactory<BatchFetchMealConstructorParams,
                  BatchFetchMealCallParams, FetchFruits>(
                useCaseFactory: (constructorParams) =>
                    FetchFruits(fruits: constructorParams.availableFruits),
                onCall: (callParams, useCase) =>
                    useCase.call(callParams.fruitCount),
              ),
              UseCaseFactory<BatchFetchMealConstructorParams,
                  BatchFetchMealCallParams, FetchVeggies>(
                useCaseFactory: (constructorParams) =>
                    FetchVeggies(veggies: constructorParams.availableVeggies),
                onCall: (callParams, useCase) =>
                    useCase.call(callParams.veggieCount),
              )
            ],
            // The second batch of use cases
            [
              UseCaseFactory<BatchFetchMealConstructorParams,
                  BatchFetchMealCallParams, FetchGrains>(
                useCaseFactory: (constructorParams) =>
                    FetchGrains(grains: constructorParams.availableGrains),
                onCall: (callParams, useCase) =>
                    useCase.call(callParams.grainCount),
              )
            ]
          ],
        );
}
 
/// The parameter passed to each [UseCaseFactory] to initialize a use case.
class BatchFetchMealConstructorParams {
  const BatchFetchMealConstructorParams({
    required this.availableFruits,
    required this.availableVeggies,
    required this.availableGrains,
  });
  
  final List<String> availableFruits;
  final List<String> availableVeggies;
  final List<String> availableGrains;
}
 
/// The parameter passed to each [UseCaseFactory] to call a use case.
class BatchFetchMealCallParams {
  const BatchFetchMealCallParams({
    required this.fruitCount,
    required this.veggieCount,
    required this.grainCount,
  });
  
  final int fruitCount;
  final int veggieCount;
  final int grainCount;
}
 
class FetchFruits extends BaseUseCase<int, Failure, List<String>> {
  FetchFruits({required this.fruits});
  
  final List<String> fruits;
  
  @override
  Future<Either<Failure, List<String>>> onCall(int params) async {
    if (params < 1) {
      return const Left(
        Failure('Number of fruits to be fetched must be greater than 0'),
      );
    }
  
    return Right(fruits.take(params).toList());
  }
}
 
class FetchVeggies extends BaseUseCase<int, Failure, List<String>> {
  FetchVeggies({required this.veggies});
  
  final List<String> veggies;
  
  @override
  Future<Either<Failure, List<String>>> onCall(int params) async {
    if (params < 1) {
      return const Left(
        Failure('Number of veggies to be fetched must be greater than 0'),
      );
    }
  
    return Right(veggies.take(params).toList());
  }
}
 
class FetchGrains extends BaseUseCase<int, Failure, List<String>> {
  FetchGrains({required this.grains});
  
  final List<String> grains;
  
  @override
  Future<Either<Failure, List<String>>> onCall(int params) async {
    if (params < 1) {
      return const Left(
        Failure('Number of grains to be fetched must be greater than 0'),
      );
    }
  
    return Right(grains.take(params).toList());
  }
}

Using a Batch Runner #

// Initialize the batch runner use case
final batchFetchMeal = BatchFetchMeal(
  constructorParams: const BatchFetchMealConstructorParams(
    availableFruits: ['Apple, Orange, Mango, Lemon'],
    availableVeggies: ['Kale', 'Garlic', 'Cabbage', 'Broccoli'],
    availableGrains: ['Barley', 'Brown Rice', 'Oatmeal', 'Millet'],
  ),
);
 
// Start the batch run
await batchFetchMeal.batchRun(
  params: const BatchFetchMealCallParams(
    fruitCount: 2,
    veggieCount: 4,
    grainCount: 3,
  ),
);
 
// View the results
 
final batchRunResult = batchRunner.batchRunResult;
 
// All use cases created and called in the batch run
print(batchRunResult?.useCases);
// The left values by all left (failed) use cases
print(batchRunResult?.leftValues<dynamic>());
// The right values by all right (successful) use cases
print(batchRunResult?.rightValues<dynamic>());
 
// Reference each use cases. If `call()` returns `null`, then that use case
// may have not been created yet by the `UseCaseFactory`
final fetchFruits = batchRunResult?.call<FetchFruits>();
final fetchVeggies = batchRunResult?.call<FetchVeggies>();
final fetchGrains = batchRunResult?.call<FetchGrains>();
 
print(fetchFruits?.leftValue);
print(fetchFruits?.rightValue);
print(fetchFruits?.value);
 
print(fetchVeggies?.leftValue);
print(fetchVeggies?.rightValue);
print(fetchVeggies?.value);
 
print(fetchGrains?.leftValue);
print(fetchGrains?.rightValue);
print(fetchGrains?.value);

Creating a Custom Use Case Factory #

By default, the UseCaseFactory does the following:

  • Creates a use case only if it has not been created yet.
  • Executes the use case only if it previously failed or has not been executed yet.

To customize this behavior, consider creating a child class of UseCaseFactory.

class CustomUseCastFactory<P1, P2, 
  UC extends BaseUseCase<dynamic, dynamic, dynamic>>
    extends UseCaseFactory<P1, P2, UC> {
  CustomUseCastFactory({
    required UC Function(P1 constructorParams) onInitialize,
    required Future<Either<dynamic, dynamic>> 
      Function(P2 callParams, UC useCase) onCall,
  }) : super(onInitialize: onInitialize, onCall: onCall);
  
  Future<Either<dynamic, dynamic>> call(P1 constructorParams, P2 callParams) async {
    // Create the logic for initializing and calling the use case
    if (useCase == null) {
      useCase = onInitialize(constructorParams);
      return onCall(callParams, useCase!);
    }
  
    final currentValue = useCase!.value;
  
    if (currentValue == null || currentValue.isLeft()) {
      return onCall(callParams, useCase!);
    }
  
    return currentValue;
  }
}

Resetting a Use Case #

If you want to set a Runner, Paginator, Watcher or a BatchRunner back to its initial state, then call reset(). This will set all their values to null and emit their initial state.

Reset State Flow
5
likes
0
pub points
18%
popularity

Publisher

verified publishercodenic.dev

A suite of abstract cubits to reduce boilerplate code and construct app use cases.

Repository (GitHub)
View/report issues

Documentation

Documentation

License

unknown (license)

Dependencies

bloc, dartz, equatable, meta

More

Packages that depend on codenic_bloc_use_case