bloc_arch_flow 1.1.0 copy "bloc_arch_flow: ^1.1.0" to clipboard
bloc_arch_flow: ^1.1.0 copied to clipboard

A Bloc extension package to easily implement TCA or MVI architecture with Bloc using fpdart.

ν•œκ΅­μ–΄ | English


Bloc Architecture Flow (bloc_arch_flow) #

bloc_arch_flow is a Dart package that extends Flutter's BLoC (Business Logic Component) pattern by integrating predictable architectural patterns like MVI (Model-View-Intent) and TCA (The Composable Architecture). This package helps structure state management logic and makes it easier to test by promoting a single-responsibility principle.

πŸš€ Key Features #

1. MVI (Model-View-Intent) Pattern Support #

The MviCubit abstract class helps you manage one-off side effects (Effects) separately from the state. You can handle UI actions like showing a snackbar or navigating to a new screen independently of state changes, which clearly separates UI and business logic.

Core

  • effects
  • emitEffect
  • emitNewState
  • close

Type

  • TcaEffect
  • TcaReducer

MviCubit

  • onIntent
  • handleIntentPerform
  • handleIntentPerformAsync

2. TCA (The Composable Architecture) Pattern Support #

The TcaBloc abstract class enables a purely functional reducer-based architecture. All business logic is defined within the reducer, which creates a predictable and easily testable state flow.

TcaCoreMixin

  • effectBuilder
  • parallelEffectBuilder
  • reduce
  • sideEffect

TcaBloc

  • handleAction

LogicState

  • LogicState

3. Test Suite #

BlocBaseTestSuite is a base class for BLoC/Cubit testing. It automates test environment setups like setUp and tearDown, making your test code more concise and reusable.

BlocBaseTestSuite

  • buildMockEnvironment
  • buildInitialState
  • buildMockBaseBloc
  • initTestSuite

TestSuiteUtilityMixin

  • whenSuccessTask
  • whenFailureTask
  • whenSuccess
  • whenFailure

TcaBlocTestSuite

  • reduceTest
  • effectBuilderTest
  • testEffect
  • testReducer
  • testSequence
  • step

Step

  • ReducerStep
  • ReducerStepAction

πŸ“¦ Installation #

Add the following dependencies to your pubspec.yaml file.

dependencies:
  fpdart: ^1.1.1
  flutter_bloc: ^9.1.1
  bloc: ^9.0.0
  bloc_arch_flow: ^1.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^10.0.0
  mocktail: ^1.0.4

Note: For immutable state management, we highly recommend using the freezed package.


πŸ’‘ Architecture Selection Guide #

Not sure which architecture to use? This guide helps you determine the most suitable pattern for your project. Follow the questions in order.

Question 1: Are most of your app's state changes simple and direct? #

  • Yes β†’ Use Cubit
  • No β†’ Go to Question 2

**Question 2: In addition to state changes, do you need side effects like displaying a snackbar or navigating to a new #

page based on a user's intent?**

  • Yes β†’ Use MVI
  • No β†’ Go to Question 3

**Question 3: Is your business logic very complex, requiring multiple user events to interact sequentially and change #

the state?**

  • Yes β†’ Use Bloc
  • No β†’ Go to Question 4

**Question 4: Do you want to separate your business logic into pure functions and build a clear action loop where the #

success or failure of an asynchronous task leads to the next action?**

  • Yes β†’ Use TCA
  • No β†’ Re-evaluate your needs and go back to Question 1.

πŸ“– Usage Examples #

MVI (Model-View-Intent) #

The MviCubit provides a clean structure for handling intents and their related state and side effects.

// MVI pattern applied to a Cubit
class CounterMviCubit extends MviCubit<CounterIntent, CounterState, CounterEffect> {
  CounterMviCubit(this._environment) : super(CounterState.initial());

  final CounterEnvironment _environment;

  @override
  Future<void> onIntent(CounterIntent intent) async {
    await intent.when(
      increment: () {
        emit(state.copyWith(count: state.count + 1));
      },
      decrement: () {
        emit(state.copyWith(count: state.count - 1));
      },
      incrementAsync: () async {
        emit(state.copyWith(isLoading: true, error: null));
        await handleIntentPerformAsync<Exception, int>(
          task: _environment.performAsyncIncrement(currentCount: state.count),
          logicState: logicState(),
        );
      },
      reset: () {
        emit(CounterState.initial());
      },
    );
  }

  LogicState<CounterState, int, Exception> logicState() {
    return LogicState<CounterState, int, Exception>(
      onSuccess: (int success, CounterState currentState) {
        emitEffect(const CounterEffect.showToast('Incremented successfully!'));
        return emitNewState(currentState.copyWith(count: success, isLoading: false));
      },
      onFailure: (Exception failure, CounterState currentState) {
        emitEffect(CounterEffect.showToast(failure.toString()));
        return emitNewState(currentState.copyWith(isLoading: false, error: failure.toString()));
      },
    );
  }
}

// INTENT
@freezed
sealed class CounterIntent with _$CounterIntent {
  const factory CounterIntent.increment() = Increment;

  const factory CounterIntent.decrement() = Decrement;

  const factory CounterIntent.incrementAsync() = IncrementAsync;

  const factory CounterIntent.reset() = Reset;
}

// EFFECT
@freezed
sealed class CounterEffect with _$CounterEffect {
  const factory CounterEffect.showToast(String message) = ShowToast;

  const factory CounterEffect.navigateTo(String route) = NavigateTo;

  const factory CounterEffect.playSound(String soundAsset) = PlaySound;
}

// STATE
@freezed
abstract class CounterState with _$CounterState {
  const factory CounterState({
    @Default(0) int count,
    @Default(false) bool isLoading,
    String? error,
  }) = _CounterState;

  factory CounterState.initial() => const CounterState();
}

// ENVIRONMENT
class CounterEnvironment {
  TaskEither<Exception, int> performAsyncIncrement({required int currentCount}) {
    return TaskEither.tryCatch(() async {
      await Future.delayed(const Duration(seconds: 1));
      if (currentCount >= 5) {
        throw Exception("Cannot increment beyond 5!");
      }
      return currentCount + 1;
    }, (e, s) => e is Exception ? e : Exception(e.toString()));
  }
}

TCA (The Composable Architecture) #

The TcaBloc enables a purely functional reducer approach for predictable state changes.

// TCA pattern applied to a BLoC
final class CounterTcaBloc extends TcaBloc<CounterActions, CounterState, CounterEnvironment> {
  CounterTcaBloc(CounterEnvironment environment)
      : super(initialState: CounterState.initial(), environment: environment);

  @override
  TcaReducer<CounterActions, CounterState> reduce(
    CounterActions action,
    CounterState currentState,
    CounterEnvironment environment,
  ) {
    return action.when(
      increment: () {
        final CounterState newState = currentState.copyWith(count: currentState.count + 1);
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      decrement: () {
        final CounterState newState = currentState.copyWith(count: currentState.count - 1);
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      incrementAsync: () {
        final CounterState newState = currentState.copyWith(isLoading: true, error: null);
        final effect = effectBuilder<Object, int>(
          task: environment.performAsyncIncrement(currentCount: currentState.count),
          onSuccess: (newCount) {
            return CounterActions.success(newCount);
          },
          onFailure: (error) {
            final String errorMessage = error is Exception ? error.toString() : 'Unknown error';
            return CounterActions.failed(errorMessage);
          },
        );
        return TcaReducer.withEffect(newState: newState, effect: effect);
      },
      reset: () {
        final CounterState newState = CounterState.initial();
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      success: (int newCount) {
        final CounterState newState = currentState.copyWith(
          isLoading: false,
          count: newCount,
          error: null,
        );
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      failed: (String error) {
        final CounterState newState = currentState.copyWith(
          isLoading: false,
          error: error,
          count: currentState.count,
        );
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      none: () {
        return TcaReducer.pure(newState: currentState, action: const CounterActions.none());
      },
      runBothTasks: () {
        final effect = parallelEffectBuilder(
          effects: [
            effectBuilder(
              task: environment.performAsyncIncrement(currentCount: currentState.count),
              onSuccess: (newCount) => CounterActions.success(newCount),
              onFailure: (error) => CounterActions.failed(error.toString()),
            ),

            effectBuilder(
              task: environment.performAsyncIncrement(currentCount: currentState.count),
              onSuccess: (_) => CounterActions.success(0),
              onFailure: (error) => CounterActions.failed(error.toString()),
            ),
          ],
          onSuccess: (successList) {
            final newCount = (successList.first as AsyncIncrementSuccess).newCount;
            return CounterActions.bothTasksSucceeded(newCount);
          },
          onFailure: (failure) {
            final error = (failure as AsyncIncrementFailed).error;
            return CounterActions.anyTaskFailed(error);
          },
        );
        return TcaReducer.withEffect(
          newState: currentState.copyWith(isLoading: true),
          effect: effect,
        );
      },
      bothTasksSucceeded: (newCount) {
        final newState = currentState.copyWith(count: newCount, isLoading: false, error: null);
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
      anyTaskFailed: (error) {
        final newState = currentState.copyWith(isLoading: false, error: error);
        return TcaReducer.pure(newState: newState, action: const CounterActions.none());
      },
    );
  }
}

// Actions
@freezed
sealed class CounterActions with _$CounterActions {
  const factory CounterActions.increment() = IncrementTCA;

  const factory CounterActions.decrement() = DecrementTCA;

  const factory CounterActions.incrementAsync() = IncrementAsyncTCA;

  const factory CounterActions.reset() = ResetTCA;

  const factory CounterActions.success(int newCount) = AsyncIncrementSuccess;

  const factory CounterActions.failed(String error) = AsyncIncrementFailed;

  const factory CounterActions.bothTasksSucceeded(int newCount) = BothTasksSucceeded;

  const factory CounterActions.anyTaskFailed(String error) = AnyTaskFailed;

  const factory CounterActions.runBothTasks() = RunBothTasks;

  const factory CounterActions.none() = NoneTCA;
}

// INTENT
@freezed
sealed class CounterIntent with _$CounterIntent {
  const factory CounterIntent.increment() = Increment;

  const factory CounterIntent.decrement() = Decrement;

  const factory CounterIntent.incrementAsync() = IncrementAsync;

  const factory CounterIntent.reset() = Reset;
}

// EFFECT
@freezed
sealed class CounterEffect with _$CounterEffect {
  const factory CounterEffect.showToast(String message) = ShowToast;

  const factory CounterEffect.navigateTo(String route) = NavigateTo;

  const factory CounterEffect.playSound(String soundAsset) = PlaySound;
}

// STATE
@freezed
abstract class CounterState with _$CounterState {
  const factory CounterState({
    @Default(0) int count,
    @Default(false) bool isLoading,
    String? error,
  }) = _CounterState;

  factory CounterState.initial() => const CounterState();
}

// ENVIRONMENT
class CounterEnvironment {
  TaskEither<Exception, int> performAsyncIncrement({required int currentCount}) {
    return TaskEither.tryCatch(() async {
      await Future.delayed(const Duration(seconds: 1));
      if (currentCount >= 5) {
        throw Exception("Cannot increment beyond 5!");
      }
      return currentCount + 1;
    }, (e, s) => e is Exception ? e : Exception(e.toString()));
  }
}

Test Suite #

Use the BlocBaseTestSuite to simplify your test environment setup and make tests more readable.

MVI #

class CounterMock extends Mock implements CounterEnvironment {}

typedef CounterCubit =
MviCubitTestSuite<CounterMviCubit, CounterIntent, CounterState, CounterEffect, CounterEnvironment>;

// Test code using BlocBaseTestSuite
class CounterCubitTestSuite extends CounterCubit {
  @override
  CounterState buildInitialState() => CounterState.initial();

  @override
  CounterMviCubit buildMockBaseBloc() => CounterMviCubit(mockEnvironment);

  @override
  CounterEnvironment buildMockEnvironment() => CounterMock();
}

void main() {
  final CounterCubitTestSuite testSuite = CounterCubitTestSuite();

  group('CounterMviCubit', () {
    testSuite.initTestSuite();

    testSuite.testState(
      'The count must increase by 1 when the Increment Intent occurs.',
      intent: const CounterIntent.increment(),
      expectedState: const CounterState(count: 1),
    );

    testSuite.testState(
      'The count must be reduced by 1 when an increment intent occurs.',
      intent: const CounterIntent.decrement(),
      expectedState: const CounterState(count: -1),
    );

    testSuite.testSideEffect(
      'Toast effects must be generated when successful IncrementAsync.',
      intent: const CounterIntent.incrementAsync(),
      expectedEffect: const CounterEffect.showToast('Incremented successfully!'),
      setUp: () {
        when(
              () =>
              testSuite.mockEnvironment.performAsyncIncrement(
                currentCount: any(named: 'currentCount'),
              ),
        ).thenAnswer((_) => testSuite.whenSuccessTask(1));
      },
    );

    testSuite.testSideEffect(
      'Toast effects must be generated when successful IncrementAsync.',
      intent: const CounterIntent.incrementAsync(),
      expectedEffect: const CounterEffect.showToast('Exception: Cannot increment beyond 5!'),
      setUp: () {
        when(
              () =>
              testSuite.mockEnvironment.performAsyncIncrement(
                currentCount: any(named: 'currentCount'),
              ),
        ).thenAnswer((_) => testSuite.whenFailureTask(Exception('Cannot increment beyond 5!')));
      },
    );

    testSuite.testCubitGroup(
      'IncrementAsync success must be loaded, final and toast effect.',
      intent: const CounterIntent.incrementAsync(),
      loadingState: const CounterState(isLoading: true),
      finalState: const CounterState(count: 1, isLoading: false),
      expectedEffect: const CounterEffect.showToast('Incremented successfully!'),
      wait: const Duration(seconds: 1, milliseconds: 1),
      setUp: () {
        when(
              () =>
              testSuite.mockEnvironment.performAsyncIncrement(
                currentCount: any(named: 'currentCount'),
              ),
        ).thenAnswer((_) => testSuite.whenSuccessTask(1));
      },
    );

    testSuite.testCubitGroup(
      'IncrementAsync failures must result in error status and error toast effect.',
      intent: const CounterIntent.incrementAsync(),
      loadingState: const CounterState(isLoading: true),
      finalState: const CounterState(
        isLoading: false,
        error: 'Exception: Cannot increment beyond 5!',
      ),
      wait: const Duration(seconds: 1, milliseconds: 50),
      expectedEffect: const CounterEffect.showToast('Exception: Cannot increment beyond 5!'),
      setUp: () {
        when(
              () =>
              testSuite.mockEnvironment.performAsyncIncrement(
                currentCount: any(named: 'currentCount'),
              ),
        ).thenAnswer((_) => testSuite.whenFailureTask(Exception('Cannot increment beyond 5!')));
      },
    );
  });
}

TCA #

class MockCounter extends Mock implements CounterEnvironment {}

// TcaBlocTestSuite<B extends TcaBloc<A, S, E>, A, S, E>
typedef CounterBloc =
TcaBlocTestSuite<CounterTcaBloc, CounterActions, CounterState, CounterEnvironment>;

class CounterBlocTestSuite extends CounterBloc {
  @override
  CounterState buildInitialState() => const CounterState();

  @override
  CounterTcaBloc buildMockBaseBloc() => CounterTcaBloc(mockEnvironment);

  @override
  CounterEnvironment buildMockEnvironment() => MockCounter();

  @override
  TcaReducer<CounterActions, CounterState> reduceTest(CounterActions action,
      CounterState currentState,
      CounterEnvironment environment,) {
    return mockBaseBloc.reduce(action, currentState, environment);
  }
}

void main() {
  group('CounterBloc', () {
    final CounterBlocTestSuite testSuite = CounterBlocTestSuite();
    testSuite.initTestSuite();

    group('testSequence', () {
      testSuite.testSequence(
        'INCREMENT-> Decrement-> Reset The condition must change correctly.',
        initialState: const CounterState(count: 1),
        steps: [
          testSuite.step(
            'Increment action increases by 6.',
            stepAction: ReducerStepAction.pure(action: const CounterActions.increment()),
            state: const CounterState(count: 6),
            effect: isA<TcaEffect>(),
          ),
          testSuite.step(
            'Count is reduced to 5 with Decrement action.',
            stepAction: ReducerStepAction.pure(action: const CounterActions.decrement()),
            state: const CounterState(count: 5),
            effect: isA<TcaEffect>(),
          ),
          testSuite.step(
            'Reset action returns to the initial state Count: 0.',
            stepAction: ReducerStepAction.pure(action: const CounterActions.reset()),
            state: CounterState.initial(),
            effect: isA<TcaEffect>(),
          ),
        ],
        wait: const Duration(milliseconds: 100),
      );
    });

    group('testEffect', () {
      testSuite.testEffect(
        'IncrementAsync successes will be updated and isLanding becomes false.',
        setUp: () {
          when(
                () =>
                testSuite.mockEnvironment.performAsyncIncrement(
                  currentCount: any(named: 'currentCount'),
                ),
          ).thenAnswer((_) => testSuite.whenSuccessTask(5));
        },
        stepAction: ReducerStepAction.success(
          action: const CounterActions.incrementAsync(),
          expected: const CounterActions.success(5),
        ),
        wait: const Duration(milliseconds: 10),
      );

      testSuite.testEffect(
        'IncrementAsync failures are set when the failure is set and isLoading becomes false.',
        setUp: () {
          final exception = Exception("Cannot increment beyond 5!");
          when(
                () =>
                testSuite.mockEnvironment.performAsyncIncrement(
                  currentCount: any(named: 'currentCount'),
                ),
          ).thenAnswer((_) => testSuite.whenFailureTask(exception));
        },
        stepAction: ReducerStepAction.failure(
          action: const CounterActions.incrementAsync(),
          expected: const CounterActions.failed("Exception: Cannot increment beyond 5!"),
        ),
        wait: const Duration(milliseconds: 10),
      );
    });

    group('testReducer', () {
      testSuite.testReducer(
        'Increment Action: Count must increase by 1.',
        action: const CounterActions.increment(),
        initialState: const CounterState(count: 0),
        expectedState: const CounterState(count: 1),
        expectedEffect: isA<TcaEffect>(),
      );

      testSuite.testReducer(
        'Decrement Action: Count must be reduced by 1.',
        action: const CounterActions.decrement(),
        initialState: const CounterState(count: 0),
        expectedState: const CounterState(count: -1),
        expectedEffect: isA<TcaEffect>(),
      );

      testSuite.testReducer(
        'Reset Action: The condition must be initialized.',
        action: const CounterActions.reset(),
        initialState: const CounterState(count: 10, isLoading: true, error: 'some error'),
        expectedState: CounterState.initial(),
        expectedEffect: isA<TcaEffect>(),
      );
    });
  });
}


βš™οΈ Migration Guide #

From 1.0.0 to 1.1.0 #

The 1.1.0 release introduces a cleaner API with the new MviCubit and TcaBloc classes.

The old BlocArchMvi and BlocArchTca classes have been deprecated in this version to facilitate a smoother transition.

They will be completely removed in version 2.0.0.

Old API (1.0.0)

// Old MVI example
class CounterMviBloc extends BlocArchMvi<CounterIntent, CounterState, CounterEffect> {
  // ...
}

// Old TCA example
class CounterTcaBloc extends BlocArchTca<CounterAction, CounterState, CounterEnvironment> {
  // ...
}

New API (1.1.0)

// New MVI example (MviCubit)
class CounterMviCubit extends MviCubit<CounterIntent, CounterState, CounterEffect> {
  // ...
}

// New TCA example (TcaBloc)
class CounterTcaBloc extends TcaBloc<CounterAction, CounterState, CounterEnvironment> {
  // ...
}

Simply replace the old abstract classes with the new MviCubit and TcaBloc classes. The core methods like mviEmitEffect and tcaReducer remain, but they are now accessed through the mixins.

0
likes
160
points
14
downloads

Publisher

unverified uploader

Weekly Downloads

A Bloc extension package to easily implement TCA or MVI architecture with Bloc using fpdart.

Repository (GitHub)
View/report issues

Topics

#tca #mvi

Documentation

API reference

License

MIT (license)

Dependencies

bloc, bloc_test, flutter, flutter_test, fpdart, meta, mocktail

More

Packages that depend on bloc_arch_flow