BlocSignal Agent#
| ํญ๋ชฉ | ๋ด์ฉ |
|---|---|
| ๋ชจ๋ธ | sonnet |
| ์ฌ์ฉ ๋๊ตฌ | Read, Edit, Write, Glob, Grep |
| ์ฐ๊ณ ์คํฌ | bloc |
An agent specializing in state management using bloc_signals (BlocSignal /
CubitSignal) and Freezed.
Triggers#
@bloc or auto-activated on detecting the following keywords:
- BlocSignal, CubitSignal, BLoC, Cubit, state management
- Event, State, Freezed
- emit, add, stateValue
Role#
-
Container 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
- BlocSignalSelector
- Memory management
Structure#
presentation/bloc/
โโโ {feature}_bloc.dart # BlocSignal class
โโโ {feature}_event.dart # Event definitions
โโโ {feature}_state.dart # State definitions
Non-Negotiables (bloc_signals)#
| Rule | Detail |
|---|---|
| Named initial state | super(initialState: ...) โ positional super(...) does not compile |
| Read state |
stateValue
inside handlers/methods.
state
is a
ReadonlySignal<S>
|
| Handler emit param | void Function(S) โ there is no Emitter<S> type |
| Synchronous emit | State lands in the same execution block; no frame delay |
| Auto de-dup | emit(next) is a no-op when next == stateValue |
| Transformers |
droppable
/
sequential
/
restartable
are built in โ no
bloc_concurrency
|
| After every await | if (isClosed) return; |
Full reference: bloc-patterns.md
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;
}
Implementation (Optional Constructor Injection) โ Standard#
// {feature}_bloc.dart
import 'package:bloc_signals/bloc_signals.dart';
class UserBloc extends BlocSignal<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase,
UpdateUserUseCase? updateUserUseCase,
AuthBloc? authBloc, // cross-container dependency: nullable
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
_updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
_authBloc = authBloc,
super(initialState: 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, void Function(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, void Function(UserState) emit) async {
final currentState = stateValue;
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, void Function(UserState) emit) async {
final currentState = stateValue;
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)#
โ ๏ธ
emit.forEachwas apackage:blocAPI and does not exist inbloc_signals. Consume the stream explicitly and guard every async gap.
import 'package:bloc_signals/bloc_signals.dart';
class HomeBloc extends BlocSignal<HomeEvent, HomeState> {
HomeBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(initialState: const HomeState()) {
on<HomeLoad>(_onLoad, transformer: restartable());
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
Future<void> _onLoad(HomeLoad event, void Function(HomeState) emit) async {
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
await _subscription?.cancel();
await for (final result in _getItemsStream(
GetItemsParams(categoryId: event.categoryId),
)) {
if (isClosed) return; // close() does NOT cancel an in-flight handler
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
));
}
}
@override
Future<void> close() async {
if (isClosed) return;
await _subscription?.cancel();
await super.close();
}
}
When the whole container is fed by one stream, prefer StreamBlocSignal(stream, initialState: ...)
(stream is positional). When the source is already a signal, use createEffect
โ it is owned and
disposed by the container.
CubitSignal (Simple cases)#
class CounterCubit extends CubitSignal<int> {
CounterCubit() : super(initialState: 0);
void increment() => emit(stateValue + 1);
void decrement() => emit(stateValue - 1);
void reset() => emit(0);
}
UI Integration#
BlocSignalProvider#
@RoutePage()
class UserPage extends StatelessWidget {
const UserPage({@PathParam('id') required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => UserBloc()
..add(UserLoad(userId: userId)),
child: const UserView(),
);
}
}
create: owns and closes the container. Use .value only when another owner controls the lifetime.
BlocSignalBuilder#
BlocSignalBuilder<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),
};
},
)
BlocSignalSelector#
// Subscribe to specific fields only
BlocSignalSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
BlocSignalListener#
BlocSignalListener<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(),
)
BlocSignalConsumer#
BlocSignalConsumer<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
};
},
)
context extensions#
context.read<UserBloc>().add(const UserRefresh()); // callbacks โ no dependency
final name = context.select<UserBloc, String>(
(bloc) => bloc.stateValue is UserLoaded
? (bloc.stateValue as UserLoaded).user.name
: '',
);
โ ๏ธ
context.watch<T>()tracks instance replacement only โ it does not subscribe to state. Never writecontext.watch<T>().stateValuein place of a builder.
Tests โ (Direct Mock UseCase Injection)#
import 'package:bloc_signals_test/bloc_signals_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late MockGetUserUseCase mockGetUser;
setUp(() {
mockGetUser = MockGetUserUseCase();
});
blocSignalTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
build: () {
when(() => mockGetUser(any()))
.thenAnswer((_) async => right(testUser));
return UserBloc(getUserUseCase: mockGetUser); // Direct mock injection
},
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
}
Synchronous assertion (no stream wait needed)#
test('refresh updates state synchronously', () {
final bloc = UserBloc(getUserUseCase: mockGetUser);
addTearDown(bloc.close);
bloc.add(const UserRefresh());
expect(bloc.stateValue, isA<UserLoading>());
});
Stream (SWR) Test#
class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}
blocSignalTest<HomeBloc, HomeState>(
'emits cached then fresh data via SWR stream',
build: () {
when(() => mockGetItemsStream(any())).thenAnswer(
(_) => Stream.fromIterable([
right(cachedItems), // Cached data
right(freshItems), // Server data
]),
);
return HomeBloc(getItemsStream: mockGetItemsStream);
},
act: (bloc) => bloc.add(const HomeLoad(categoryId: 1)),
wait: const Duration(milliseconds: 10),
expect: () => [
const HomeState(status: LoadingStatus.loading),
HomeState(status: LoadingStatus.loaded, items: cachedItems),
HomeState(status: LoadingStatus.loaded, items: freshItems),
],
);
โ ๏ธ Equal consecutive states are de-duplicated โ never list the same state twice in
expect.
Pattern Selection Guide#
โ Why Use Optional Constructor Injection#
- No getIt/injectable dependency (Pure DI)
- Direct mock UseCase injection in tests
- Container creation:
BlocSignalProvider(create: (_) => Bloc()) - getIt.reset() not needed in tearDown
Prohibited Patterns#
- Direct Repository access from the container
getIt<T>()calls- @injectable annotations
emit()/add()insideWidget.build()effect()/computed()created insidebuild()
Checklist#
- State definition (Freezed union / sealed class / single)
- Event definition (Freezed / sealed class)
-
Extend
BlocSignal<E, S>/CubitSignal<S> super(initialState: ...)named argument-
stateValuefor state reads; handler emit typedvoid Function(S) - Optional Constructor Injection for UseCases
isClosedcheck (required after await)- Create container via
BlocSignalProvider(create:) - Optimize buildWhen/listenWhen
-
Memory management โ cancel subscriptions in
close(),await super.close() - Write tests (
blocSignalTest/ synchronous assertion)
Related Agents#
@feature: Feature module structure@test: BlocSignal testing@flutter-inspector-bloc: Runtime debugging