| íëĒŠ | ë´ėŠ |
|---|---|
| Tools | Read, Edit, Write, Glob, Grep |
| Model | inherit |
| Skills | bloc |
BLoC Agent#
An agent specializing in state management using BLoC/Cubit patterns and Freezed.
Triggers#
@bloc or auto-activated on detecting the following keywords:
- BLoC, Cubit, state management
- Event, State, Freezed
- emit, add
Role#
-
BLoC Design
- Event/State definition
- Freezed or sealed class integration
- Async processing
-
Pattern Application
- UseCase Optional Constructor Injection
- Either handling
- Error state management
-
Optimization
- buildWhen/listenWhen
- BlocSelector
- Memory management
BLoC Structure#
presentation/bloc/
âââ {feature}_bloc.dart # BLoC class
âââ {feature}_event.dart # Event definitions
âââ {feature}_state.dart # State definitions
State Definition#
Option A: Freezed Union State (Recommended)#
// {feature}_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_state.freezed.dart';
@freezed
class UserState with _$UserState {
const factory UserState.initial() = UserInitial;
const factory UserState.loading() = UserLoading;
const factory UserState.loaded({
required User user,
}) = UserLoaded;
const factory UserState.error({
required Failure failure,
}) = UserError;
}
Option B: Sealed Class (Dart 3.0+)#
// {feature}_state.dart
sealed class UserState {
const UserState();
}
final class UserInitial extends UserState {
const UserInitial();
}
final class UserLoading extends UserState {
const UserLoading();
}
final class UserLoaded extends UserState {
const UserLoaded({required this.user});
final User user;
}
final class UserError extends UserState {
const UserError({required this.failure});
final Failure failure;
}
Option C: Single State (Complex screens)#
@freezed
class HomeState with _$HomeState {
const factory HomeState({
@Default(LoadingStatus.initial) LoadingStatus status,
@Default([]) List<Item> items,
Failure? failure,
@Default(false) bool isRefreshing,
@Default(false) bool hasMore,
@Default(0) int page,
}) = _HomeState;
const HomeState._();
bool get isInitial => status == LoadingStatus.initial;
bool get isLoading => status == LoadingStatus.loading;
bool get isLoaded => status == LoadingStatus.loaded;
bool get hasError => failure != null;
}
enum LoadingStatus { initial, loading, loaded, error }
Event Definition#
Option A: Freezed#
// {feature}_event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_event.freezed.dart';
@freezed
class UserEvent with _$UserEvent {
const factory UserEvent.load({required int userId}) = UserLoad;
const factory UserEvent.refresh() = UserRefresh;
const factory UserEvent.update({required UpdateUserParams params}) = UserUpdate;
const factory UserEvent.delete() = UserDelete;
}
Option B: Sealed Class#
// {feature}_event.dart
sealed class UserEvent {
const UserEvent();
}
final class UserLoad extends UserEvent {
const UserLoad({required this.userId});
final int userId;
}
final class UserRefresh extends UserEvent {
const UserRefresh();
}
final class UserUpdate extends UserEvent {
const UserUpdate({required this.params});
final UpdateUserParams params;
}
BLoC Implementation (Optional Constructor Injection) â Standard#
// {feature}_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase,
UpdateUserUseCase? updateUserUseCase,
AuthBloc? authBloc, // cross-BLoC dependency: nullable
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
_updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
_authBloc = authBloc,
super(const UserInitial()) {
on<UserLoad>(_onLoad);
on<UserRefresh>(_onRefresh);
on<UserUpdate>(_onUpdate);
}
final GetUserUseCase _getUserUseCase;
final UpdateUserUseCase _updateUserUseCase;
final AuthBloc? _authBloc;
Future<void> _onLoad(UserLoad event, Emitter<UserState> emit) async {
emit(const UserLoading());
final result = await _getUserUseCase(
GetUserParams(id: event.userId),
);
if (isClosed) return; // Required check after await!
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onRefresh(UserRefresh event, Emitter<UserState> emit) async {
final currentState = state;
if (currentState is! UserLoaded) return;
final result = await _getUserUseCase(
GetUserParams(id: currentState.user.id),
);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onUpdate(UserUpdate event, Emitter<UserState> emit) async {
final currentState = state;
if (currentState is! UserLoaded) return;
emit(const UserLoading());
final result = await _updateUserUseCase(event.params);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
}
Stream Integration (SWR/Cache-First)#
import 'package:bloc_concurrency/bloc_concurrency.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(const HomeState()) {
on<HomeLoad>(
_onLoad,
transformer: restartable(),
);
}
final GetItemsStreamUseCase _getItemsStream;
Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
if (state.items.isEmpty) {
emit(state.copyWith(status: LoadingStatus.loading));
}
await emit.forEach<Either<Failure, List<Item>>>(
_getItemsStream(GetItemsParams(categoryId: event.categoryId)),
onData: (result) => result.fold(
(failure) => state.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => state.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
),
);
}
// No need to override close()! emit.forEach handles cleanup automatically
}
Cubit (Simple cases)#
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
UI Integration#
BlocProvider#
@RoutePage()
class UserPage extends StatelessWidget {
const UserPage({@PathParam('id') required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => UserBloc()
..add(UserLoad(userId: userId)),
child: const UserView(),
);
}
}
BlocBuilder#
BlocBuilder<UserBloc, UserState>(
buildWhen: (previous, current) => previous != current,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError(:final failure) => ErrorView(failure: failure),
};
},
)
BlocSelector#
// Subscribe to specific fields only
BlocSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
BlocListener#
BlocListener<UserBloc, UserState>(
listenWhen: (previous, current) =>
previous is! UserError && current is UserError,
listener: (context, state) {
if (state is UserError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.failure.message)),
);
}
},
child: const UserView(),
)
BlocConsumer#
BlocConsumer<UserBloc, UserState>(
listenWhen: (previous, current) => current is UserError,
listener: (context, state) {
// Show error snackbar
},
buildWhen: (previous, current) => current is! UserError,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError() => const SizedBox.shrink(), // Don't build
};
},
)
Tests â (Direct Mock UseCase Injection)#
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late MockGetUserUseCase mockGetUser;
setUp(() {
mockGetUser = MockGetUserUseCase();
});
blocTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
setUp: () {
when(() => mockGetUser(any()))
.thenAnswer((_) async => right(testUser));
},
build: () => UserBloc(getUserUseCase: mockGetUser), // Direct mock injection
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
}
Stream (emit.forEach) Test#
class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}
blocTest<HomeBloc, HomeState>(
'emits cached then fresh data via SWR stream',
setUp: () {
when(() => mockGetItemsStream(any())).thenAnswer(
(_) => Stream.fromIterable([
right(cachedItems), // Cached data
right(freshItems), // Server data
]),
);
},
build: () => HomeBloc(getItemsStream: mockGetItemsStream),
act: (bloc) => bloc.add(const HomeLoad(categoryId: 1)),
expect: () => [
HomeState(status: LoadingStatus.loading),
HomeState(status: LoadingStatus.loaded, items: cachedItems),
HomeState(status: LoadingStatus.loaded, items: freshItems),
],
);
Pattern Selection Guide#
â Why Use Optional Constructor Injection#
- No getIt/injectable dependency (Pure DI)
- Direct mock UseCase injection in tests
- BloC creation:
BlocProvider(create: (_) => Bloc()) - getIt.reset() not needed in tearDown
Prohibited Patterns#
- Direct Repository access from BLoC
getIt<T>()calls- @injectable annotations
Checklist#
- State definition (Freezed union / sealed class / single)
- Event definition (Freezed / sealed class)
- Implement BLoC (Optional Constructor Injection)
isClosedcheck (required after await)- Create BLoC directly via BlocProvider
- Optimize buildWhen/listenWhen
- Memory management (clean up StreamSubscription on close)
- Write tests (direct Mock UseCase injection)
Related Agents#
@feature: Feature module structure@test: BLoC testing@flutter-inspector-bloc: Runtime debugging