bloc_arch_flow 1.1.0
bloc_arch_flow: ^1.1.0 copied to clipboard
A Bloc extension package to easily implement TCA or MVI architecture with Bloc using fpdart.
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
effectsemitEffectemitNewStateclose
Type
TcaEffectTcaReducer
MviCubit
onIntenthandleIntentPerformhandleIntentPerformAsync
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
effectBuilderparallelEffectBuilderreducesideEffect
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
buildMockEnvironmentbuildInitialStatebuildMockBaseBlocinitTestSuite
TestSuiteUtilityMixin
whenSuccessTaskwhenFailureTaskwhenSuccesswhenFailure
TcaBlocTestSuite
reduceTesteffectBuilderTesttestEffecttestReducertestSequencestep
Step
ReducerStepReducerStepAction
π¦ 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
freezedpackage.
π‘ 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.