๐จ Skill: State Management con BLoC Avanzado
๐ Metadata
| Atributo | Valor |
|---|---|
| ID | flutter-bloc-advanced |
| Nivel | ๐ด Avanzado |
| Versiรณn | 1.0.0 |
| Keywords | bloc, cubit, state-management-bloc, flutter-bloc, hydrated-bloc |
| Referencia | BLoC Official Docs |
๐ Keywords para Invocaciรณn
Usa cualquiera de estos keywords en tus prompts para invocar este skill:
bloccubitflutter-blocbloc-advancedhydrated-bloc@skill:bloc-advanced
Ejemplos de Prompts
Crea una app con bloc avanzado y persistencia
Implementa state management con cubit para un mรณdulo de productos
@skill:bloc-advanced - Genera una app con BLoC y manejo de eventos complejos
๐ Descripciรณn
BLoC (Business Logic Component) es un patrรณn de gestiรณn de estado que separa la lรณgica de negocio de la UI mediante streams. Este skill cubre tรฉcnicas avanzadas como Hydrated BLoC para persistencia, Replay BLoC para debugging, transformers para control de eventos, y estrategias de testing exhaustivas.
โ ๏ธ IMPORTANTE: Todos los comandos de este skill deben ejecutarse desde la raรญz del proyecto (donde existe el directorio mobile/). El skill incluye verificaciones para asegurar que se estรก en el directorio correcto antes de ejecutar cualquier comando.
โ Cuรกndo Usar Este Skill
- Aplicaciones enterprise con lรณgica compleja
- Necesitas separaciรณn estricta entre UI y lรณgica
- Requieres testing exhaustivo de lรณgica de negocio
- Necesitas persistencia automรกtica del estado
- Quieres replay/undo de estados para debugging
- Aplicaciones con flujos de eventos complejos
- Necesitas transformers para debounce/throttle/retry
โ Cuรกndo NO Usar Este Skill
- Proyectos muy simples (usa setState o Provider)
- El equipo no estรก familiarizado con reactive programming
- No necesitas la robustez que ofrece BLoC
๐๏ธ Estructura del Proyecto
lib/
โโโ core/
โ โโโ bloc/
โ โ โโโ bloc_observer.dart
โ โ โโโ app_bloc_observer.dart
โ โโโ error/
โ โ โโโ failures.dart
โ โ โโโ exceptions.dart
โ โโโ utils/
โ โโโ bloc_transformers.dart
โ
โโโ features/
โ โโโ auth/
โ โ โโโ data/
โ โ โ โโโ datasources/
โ โ โ โ โโโ auth_local_datasource.dart
โ โ โ โ โโโ auth_remote_datasource.dart
โ โ โ โโโ models/
โ โ โ โ โโโ user_model.dart
โ โ โ โโโ repositories/
โ โ โ โโโ auth_repository_impl.dart
โ โ โโโ domain/
โ โ โ โโโ entities/
โ โ โ โ โโโ user.dart
โ โ โ โโโ repositories/
โ โ โ โ โโโ auth_repository.dart
โ โ โ โโโ usecases/
โ โ โ โโโ login_usecase.dart
โ โ โ โโโ logout_usecase.dart
โ โ โ โโโ get_current_user_usecase.dart
โ โ โโโ presentation/
โ โ โโโ bloc/
โ โ โ โโโ auth_bloc.dart
โ โ โ โโโ auth_event.dart
โ โ โ โโโ auth_state.dart
โ โ โ โโโ login/
โ โ โ โโโ login_cubit.dart
โ โ โ โโโ login_state.dart
โ โ โโโ screens/
โ โ โ โโโ login_screen.dart
โ โ โ โโโ register_screen.dart
โ โ โโโ widgets/
โ โ โโโ login_form.dart
โ โ
โ โโโ products/
โ โโโ data/
โ โ โโโ datasources/
โ โ โ โโโ product_remote_datasource.dart
โ โ โโโ models/
โ โ โ โโโ product_model.dart
โ โ โโโ repositories/
โ โ โโโ product_repository_impl.dart
โ โโโ domain/
โ โ โโโ entities/
โ โ โ โโโ product.dart
โ โ โโโ repositories/
โ โ โ โโโ product_repository.dart
โ โ โโโ usecases/
โ โ โโโ get_products_usecase.dart
โ โ โโโ search_products_usecase.dart
โ โ โโโ add_to_cart_usecase.dart
โ โโโ presentation/
โ โโโ bloc/
โ โ โโโ products_bloc.dart
โ โ โโโ products_event.dart
โ โ โโโ products_state.dart
โ โ โโโ product_detail/
โ โ โ โโโ product_detail_cubit.dart
โ โ โ โโโ product_detail_state.dart
โ โ โโโ cart/
โ โ โโโ cart_bloc.dart
โ โ โโโ cart_event.dart
โ โ โโโ cart_state.dart
โ โโโ screens/
โ โ โโโ products_screen.dart
โ โ โโโ product_detail_screen.dart
โ โ โโโ cart_screen.dart
โ โโโ widgets/
โ โโโ product_card.dart
โ โโโ cart_item.dart
โ
โโโ main.dart
๐ฆ Dependencias Requeridas
dependencies:
flutter:
sdk: flutter
# BLoC core
flutter_bloc: ^8.1.3
bloc: ^8.1.2
# BLoC extras
hydrated_bloc: ^9.1.2 # Persistencia automรกtica
replay_bloc: ^0.2.3 # Replay/undo functionality
# Utilities
equatable: ^2.0.5 # Para comparaciรณn de estados
freezed_annotation: ^2.4.1 # Immutability
json_annotation: ^4.8.1
# Dependency Injection
get_it: ^7.6.4
injectable: ^2.3.2
# Storage para Hydrated BLoC
path_provider: ^2.1.1
dev_dependencies:
# Code generation
build_runner: ^2.4.6
freezed: ^2.4.5
json_serializable: ^6.7.1
injectable_generator: ^2.4.1
# Testing
bloc_test: ^9.1.4
mocktail: ^1.0.1
๐ป Implementaciรณn
1. Setup Inicial con BLoC Observer
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'core/bloc/app_bloc_observer.dart';
import 'core/di/injection.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/screens/login_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configurar storage para Hydrated BLoC
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
// Configurar BLoC observer para logging
Bloc.observer = AppBlocObserver();
// Configurar dependency injection
configureDependencies();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
// BLoC global de autenticaciรณn
BlocProvider<AuthBloc>(
create: (context) => getIt<AuthBloc>()
..add(const AuthCheckRequested()),
),
// Puedes agregar mรกs BLoCs globales aquรญ
],
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp(
title: 'BLoC Advanced App',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: state.maybeWhen(
authenticated: (user) => const HomeScreen(),
unauthenticated: () => const LoginScreen(),
orElse: () => const SplashScreen(),
),
);
},
),
);
}
}
BLoC Observer para Logging
// lib/core/bloc/app_bloc_observer.dart
import 'package:flutter/foundation.dart';
import 'package:bloc/bloc.dart';
class AppBlocObserver extends BlocObserver {
@override
void onCreate(BlocBase bloc) {
super.onCreate(bloc);
debugPrint('๐ฆ onCreate -- ${bloc.runtimeType}');
}
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
debugPrint('๐จ onEvent -- ${bloc.runtimeType}, $event');
}
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
if (kDebugMode) {
debugPrint('๐ onChange -- ${bloc.runtimeType}');
debugPrint(' currentState: ${change.currentState}');
debugPrint(' nextState: ${change.nextState}');
}
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
if (kDebugMode) {
debugPrint('๐ onTransition -- ${bloc.runtimeType}');
debugPrint(' event: ${transition.event}');
debugPrint(' currentState: ${transition.currentState}');
debugPrint(' nextState: ${transition.nextState}');
}
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
debugPrint('โ onError -- ${bloc.runtimeType}, $error');
debugPrint('StackTrace: $stackTrace');
super.onError(bloc, error, stackTrace);
}
@override
void onClose(BlocBase bloc) {
super.onClose(bloc);
debugPrint('๐๏ธ onClose -- ${bloc.runtimeType}');
}
}
2. BLoC Pattern Completo
Domain Layer
// lib/features/products/domain/entities/product.dart
import 'package:equatable/equatable.dart';
class Product extends Equatable {
final String id;
final String name;
final String description;
final double price;
final String imageUrl;
final int stock;
final List<String> tags;
const Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.imageUrl,
required this.stock,
this.tags = const [],
});
@override
List<Object?> get props => [id, name, description, price, imageUrl, stock, tags];
}
// lib/features/products/domain/usecases/get_products_usecase.dart
import 'package:dartz/dartz.dart';
import '../../../core/error/failures.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
class GetProductsUseCase {
final ProductRepository repository;
GetProductsUseCase(this.repository);
Future<Either<Failure, List<Product>>> call({
String? category,
String? searchQuery,
int page = 1,
int limit = 20,
}) async {
return await repository.getProducts(
category: category,
searchQuery: searchQuery,
page: page,
limit: limit,
);
}
}
Presentation Layer - Events
// lib/features/products/presentation/bloc/products_event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'products_event.freezed.dart';
@freezed
class ProductsEvent with _$ProductsEvent {
const factory ProductsEvent.started() = ProductsStarted;
const factory ProductsEvent.loadProducts({
String? category,
@Default(1) int page,
}) = ProductsLoadRequested;
const factory ProductsEvent.refreshProducts() = ProductsRefreshRequested;
const factory ProductsEvent.searchProducts(String query) = ProductsSearchRequested;
const factory ProductsEvent.loadMoreProducts() = ProductsLoadMoreRequested;
const factory ProductsEvent.filterByCategory(String category) = ProductsFilterByCategoryRequested;
const factory ProductsEvent.clearFilters() = ProductsClearFiltersRequested;
}
Presentation Layer - States
// lib/features/products/presentation/bloc/products_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/product.dart';
part 'products_state.freezed.dart';
@freezed
class ProductsState with _$ProductsState {
const factory ProductsState.initial() = ProductsInitial;
const factory ProductsState.loading() = ProductsLoading;
const factory ProductsState.loaded({
required List<Product> products,
@Default(false) bool hasReachedMax,
@Default(1) int currentPage,
String? category,
String? searchQuery,
}) = ProductsLoaded;
const factory ProductsState.loadingMore({
required List<Product> products,
@Default(1) int currentPage,
String? category,
String? searchQuery,
}) = ProductsLoadingMore;
const factory ProductsState.error(String message) = ProductsError;
}
Presentation Layer - BLoC
// lib/features/products/presentation/bloc/products_bloc.dart
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/product.dart';
import '../../domain/usecases/get_products_usecase.dart';
import 'products_event.dart';
import 'products_state.dart';
part 'products_bloc.freezed.dart';
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
final GetProductsUseCase getProductsUseCase;
ProductsBloc({
required this.getProductsUseCase,
}) : super(const ProductsState.initial()) {
on<ProductsStarted>(_onStarted);
on<ProductsLoadRequested>(
_onLoadRequested,
transformer: restartable(),
);
on<ProductsRefreshRequested>(_onRefreshRequested);
on<ProductsSearchRequested>(
_onSearchRequested,
transformer: debounce(const Duration(milliseconds: 300)),
);
on<ProductsLoadMoreRequested>(_onLoadMoreRequested);
on<ProductsFilterByCategoryRequested>(_onFilterByCategoryRequested);
on<ProductsClearFiltersRequested>(_onClearFiltersRequested);
}
Future<void> _onStarted(
ProductsStarted event,
Emitter<ProductsState> emit,
) async {
emit(const ProductsState.loading());
await _loadProducts(emit: emit);
}
Future<void> _onLoadRequested(
ProductsLoadRequested event,
Emitter<ProductsState> emit,
) async {
emit(const ProductsState.loading());
await _loadProducts(
emit: emit,
category: event.category,
page: event.page,
);
}
Future<void> _onRefreshRequested(
ProductsRefreshRequested event,
Emitter<ProductsState> emit,
) async {
final currentState = state;
// Mantener filtros si existen
String? category;
String? searchQuery;
currentState.mapOrNull(
loaded: (state) {
category = state.category;
searchQuery = state.searchQuery;
},
);
await _loadProducts(
emit: emit,
category: category,
searchQuery: searchQuery,
page: 1,
);
}
Future<void> _onSearchRequested(
ProductsSearchRequested event,
Emitter<ProductsState> emit,
) async {
emit(const ProductsState.loading());
await _loadProducts(
emit: emit,
searchQuery: event.query,
page: 1,
);
}
Future<void> _onLoadMoreRequested(
ProductsLoadMoreRequested event,
Emitter<ProductsState> emit,
) async {
final currentState = state;
await currentState.mapOrNull(
loaded: (state) async {
if (state.hasReachedMax) return;
final nextPage = state.currentPage + 1;
emit(ProductsState.loadingMore(
products: state.products,
currentPage: state.currentPage,
category: state.category,
searchQuery: state.searchQuery,
));
await _loadProducts(
emit: emit,
category: state.category,
searchQuery: state.searchQuery,
page: nextPage,
existingProducts: state.products,
);
},
);
}
Future<void> _onFilterByCategoryRequested(
ProductsFilterByCategoryRequested event,
Emitter<ProductsState> emit,
) async {
emit(const ProductsState.loading());
await _loadProducts(
emit: emit,
category: event.category,
page: 1,
);
}
Future<void> _onClearFiltersRequested(
ProductsClearFiltersRequested event,
Emitter<ProductsState> emit,
) async {
emit(const ProductsState.loading());
await _loadProducts(emit: emit, page: 1);
}
Future<void> _loadProducts({
required Emitter<ProductsState> emit,
String? category,
String? searchQuery,
int page = 1,
List<Product> existingProducts = const [],
}) async {
final result = await getProductsUseCase(
category: category,
searchQuery: searchQuery,
page: page,
);
result.fold(
(failure) => emit(ProductsState.error(failure.message)),
(newProducts) {
final allProducts = page > 1
? [...existingProducts, ...newProducts]
: newProducts;
emit(ProductsState.loaded(
products: allProducts,
hasReachedMax: newProducts.isEmpty,
currentPage: page,
category: category,
searchQuery: searchQuery,
));
},
);
}
}
// Transformers personalizados
EventTransformer<T> debounce<T>(Duration duration) {
return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}
EventTransformer<T> restartable<T>() {
return (events, mapper) => events.switchMap(mapper);
}
3. Cubit Pattern (mรกs simple que BLoC)
// lib/features/products/presentation/bloc/product_detail/product_detail_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../domain/entities/product.dart';
import '../../../domain/usecases/get_product_by_id_usecase.dart';
part 'product_detail_state.dart';
part 'product_detail_cubit.freezed.dart';
class ProductDetailCubit extends Cubit<ProductDetailState> {
final GetProductByIdUseCase getProductByIdUseCase;
ProductDetailCubit({
required this.getProductByIdUseCase,
}) : super(const ProductDetailState.initial());
Future<void> loadProduct(String productId) async {
emit(const ProductDetailState.loading());
final result = await getProductByIdUseCase(productId);
result.fold(
(failure) => emit(ProductDetailState.error(failure.message)),
(product) => emit(ProductDetailState.loaded(product)),
);
}
void incrementQuantity() {
state.mapOrNull(
loaded: (state) {
if (state.quantity < state.product.stock) {
emit(state.copyWith(quantity: state.quantity + 1));
}
},
);
}
void decrementQuantity() {
state.mapOrNull(
loaded: (state) {
if (state.quantity > 1) {
emit(state.copyWith(quantity: state.quantity - 1));
}
},
);
}
void toggleFavorite() {
state.mapOrNull(
loaded: (state) {
emit(state.copyWith(isFavorite: !state.isFavorite));
},
);
}
}
// lib/features/products/presentation/bloc/product_detail/product_detail_state.dart
part of 'product_detail_cubit.dart';
@freezed
class ProductDetailState with _$ProductDetailState {
const factory ProductDetailState.initial() = ProductDetailInitial;
const factory ProductDetailState.loading() = ProductDetailLoading;
const factory ProductDetailState.loaded(
Product product, {
@Default(1) int quantity,
@Default(false) bool isFavorite,
}) = ProductDetailLoaded;
const factory ProductDetailState.error(String message) = ProductDetailError;
}
4. Hydrated BLoC para Persistencia
// lib/features/auth/presentation/bloc/auth_bloc.dart
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/user.dart';
import '../../domain/usecases/login_usecase.dart';
import '../../domain/usecases/logout_usecase.dart';
import '../../domain/usecases/get_current_user_usecase.dart';
import 'auth_event.dart';
import 'auth_state.dart';
part 'auth_bloc.freezed.dart';
part 'auth_bloc.g.dart';
class AuthBloc extends HydratedBloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
final LogoutUseCase logoutUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthBloc({
required this.loginUseCase,
required this.logoutUseCase,
required this.getCurrentUserUseCase,
}) : super(const AuthState.initial()) {
on<AuthCheckRequested>(_onCheckRequested);
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
}
Future<void> _onCheckRequested(
AuthCheckRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.loading());
final result = await getCurrentUserUseCase();
result.fold(
(failure) => emit(const AuthState.unauthenticated()),
(user) => emit(AuthState.authenticated(user)),
);
}
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.loading());
final result = await loginUseCase(
email: event.email,
password: event.password,
);
result.fold(
(failure) => emit(AuthState.error(failure.message)),
(user) => emit(AuthState.authenticated(user)),
);
}
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await logoutUseCase();
emit(const AuthState.unauthenticated());
}
// Persistencia: serializar estado a JSON
@override
AuthState? fromJson(Map<String, dynamic> json) {
try {
return AuthState.fromJson(json);
} catch (_) {
return null;
}
}
// Persistencia: deserializar estado de JSON
@override
Map<String, dynamic>? toJson(AuthState state) {
// Solo persistir estado authenticated
return state.maybeMap(
authenticated: (state) => state.toJson(),
orElse: () => null,
);
}
}
5. Replay BLoC para Debugging
// lib/features/products/presentation/bloc/cart/cart_bloc.dart
import 'package:replay_bloc/replay_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/product.dart';
import 'cart_event.dart';
import 'cart_state.dart';
part 'cart_bloc.freezed.dart';
class CartBloc extends ReplayBloc<CartEvent, CartState> {
CartBloc() : super(const CartState.empty()) {
on<CartAddProduct>(_onAddProduct);
on<CartRemoveProduct>(_onRemoveProduct);
on<CartUpdateQuantity>(_onUpdateQuantity);
on<CartClear>(_onClear);
}
void _onAddProduct(CartAddProduct event, Emitter<CartState> emit) {
state.map(
empty: (_) => emit(CartState.loaded(
items: {event.product.id: CartItem(product: event.product, quantity: 1)},
)),
loaded: (state) {
final items = Map<String, CartItem>.from(state.items);
if (items.containsKey(event.product.id)) {
final existingItem = items[event.product.id]!;
items[event.product.id] = existingItem.copyWith(
quantity: existingItem.quantity + 1,
);
} else {
items[event.product.id] = CartItem(
product: event.product,
quantity: 1,
);
}
emit(state.copyWith(items: items));
},
);
}
void _onRemoveProduct(CartRemoveProduct event, Emitter<CartState> emit) {
state.mapOrNull(
loaded: (state) {
final items = Map<String, CartItem>.from(state.items);
items.remove(event.productId);
if (items.isEmpty) {
emit(const CartState.empty());
} else {
emit(state.copyWith(items: items));
}
},
);
}
void _onUpdateQuantity(CartUpdateQuantity event, Emitter<CartState> emit) {
state.mapOrNull(
loaded: (state) {
final items = Map<String, CartItem>.from(state.items);
final item = items[event.productId];
if (item != null) {
if (event.quantity <= 0) {
items.remove(event.productId);
} else {
items[event.productId] = item.copyWith(quantity: event.quantity);
}
if (items.isEmpty) {
emit(const CartState.empty());
} else {
emit(state.copyWith(items: items));
}
}
},
);
}
void _onClear(CartClear event, Emitter<CartState> emit) {
emit(const CartState.empty());
}
}
// Uso de Replay BLoC en UI
class CartScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CartBloc(),
child: Scaffold(
appBar: AppBar(
title: Text('Cart'),
actions: [
// Botones de undo/redo
BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
final bloc = context.read<CartBloc>();
return Row(
children: [
IconButton(
icon: Icon(Icons.undo),
onPressed: bloc.canUndo ? bloc.undo : null,
),
IconButton(
icon: Icon(Icons.redo),
onPressed: bloc.canRedo ? bloc.redo : null,
),
],
);
},
),
],
),
body: BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return state.map(
empty: (_) => Center(child: Text('Cart is empty')),
loaded: (state) => ListView.builder(
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items.values.elementAt(index);
return CartItemWidget(item: item);
},
),
);
},
),
),
);
}
}
6. Uso de BLoC en Widgets
BlocBuilder
class ProductsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Products')),
body: BlocBuilder<ProductsBloc, ProductsState>(
builder: (context, state) {
return state.map(
initial: (_) => Center(child: Text('Press button to load')),
loading: (_) => Center(child: CircularProgressIndicator()),
loaded: (state) => ProductsList(products: state.products),
loadingMore: (state) => ProductsList(
products: state.products,
isLoadingMore: true,
),
error: (state) => ErrorWidget(message: state.message),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<ProductsBloc>().add(
const ProductsEvent.loadProducts(),
);
},
child: Icon(Icons.refresh),
),
);
}
}
BlocListener para Side Effects
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LoginCubit(
loginUseCase: getIt<LoginUseCase>(),
),
child: Scaffold(
appBar: AppBar(title: Text('Login')),
body: BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
// Side effects aquรญ
state.mapOrNull(
success: (state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login successful!')),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomeScreen()),
);
},
error: (state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
},
);
},
child: LoginForm(),
),
),
);
}
}
BlocConsumer (Builder + Listener combinados)
class ProductDetailScreen extends StatelessWidget {
final String productId;
const ProductDetailScreen({required this.productId});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ProductDetailCubit(
getProductByIdUseCase: getIt<GetProductByIdUseCase>(),
)..loadProduct(productId),
child: Scaffold(
appBar: AppBar(title: Text('Product Detail')),
body: BlocConsumer<ProductDetailCubit, ProductDetailState>(
listener: (context, state) {
// Side effects
state.mapOrNull(
error: (state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
},
);
},
builder: (context, state) {
// UI
return state.map(
initial: (_) => SizedBox.shrink(),
loading: (_) => Center(child: CircularProgressIndicator()),
loaded: (state) => ProductDetailContent(
product: state.product,
quantity: state.quantity,
isFavorite: state.isFavorite,
),
error: (state) => ErrorWidget(message: state.message),
);
},
),
),
);
}
}
7. Testing con BLoC Test
// test/features/products/presentation/bloc/products_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetProductsUseCase extends Mock implements GetProductsUseCase {}
void main() {
late ProductsBloc bloc;
late MockGetProductsUseCase mockGetProductsUseCase;
setUp(() {
mockGetProductsUseCase = MockGetProductsUseCase();
bloc = ProductsBloc(getProductsUseCase: mockGetProductsUseCase);
});
tearDown(() {
bloc.close();
});
group('ProductsBloc', () {
final tProducts = [
Product(
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 10.0,
imageUrl: 'url1',
stock: 5,
),
Product(
id: '2',
name: 'Product 2',
description: 'Description 2',
price: 20.0,
imageUrl: 'url2',
stock: 10,
),
];
test('initial state is ProductsInitial', () {
expect(bloc.state, equals(const ProductsState.initial()));
});
blocTest<ProductsBloc, ProductsState>(
'emits [loading, loaded] when load products succeeds',
build: () {
when(() => mockGetProductsUseCase(
category: any(named: 'category'),
searchQuery: any(named: 'searchQuery'),
page: any(named: 'page'),
limit: any(named: 'limit'),
)).thenAnswer((_) async => Right(tProducts));
return bloc;
},
act: (bloc) => bloc.add(const ProductsEvent.loadProducts()),
expect: () => [
const ProductsState.loading(),
ProductsState.loaded(
products: tProducts,
hasReachedMax: false,
currentPage: 1,
),
],
verify: (_) {
verify(() => mockGetProductsUseCase(
category: null,
searchQuery: null,
page: 1,
limit: 20,
)).called(1);
},
);
blocTest<ProductsBloc, ProductsState>(
'emits [loading, error] when load products fails',
build: () {
when(() => mockGetProductsUseCase(
category: any(named: 'category'),
searchQuery: any(named: 'searchQuery'),
page: any(named: 'page'),
limit: any(named: 'limit'),
)).thenAnswer((_) async => Left(ServerFailure('Server error')));
return bloc;
},
act: (bloc) => bloc.add(const ProductsEvent.loadProducts()),
expect: () => [
const ProductsState.loading(),
const ProductsState.error('Server error'),
],
);
blocTest<ProductsBloc, ProductsState>(
'emits correct states when loading more products',
build: () {
when(() => mockGetProductsUseCase(
category: any(named: 'category'),
searchQuery: any(named: 'searchQuery'),
page: any(named: 'page'),
limit: any(named: 'limit'),
)).thenAnswer((_) async => Right(tProducts));
return bloc;
},
seed: () => ProductsState.loaded(
products: tProducts,
currentPage: 1,
),
act: (bloc) => bloc.add(const ProductsEvent.loadMoreProducts()),
expect: () => [
ProductsState.loadingMore(products: tProducts, currentPage: 1),
ProductsState.loaded(
products: [...tProducts, ...tProducts],
currentPage: 2,
),
],
);
blocTest<ProductsBloc, ProductsState>(
'debounces search events',
build: () {
when(() => mockGetProductsUseCase(
category: any(named: 'category'),
searchQuery: any(named: 'searchQuery'),
page: any(named: 'page'),
limit: any(named: 'limit'),
)).thenAnswer((_) async => Right(tProducts));
return bloc;
},
act: (bloc) async {
bloc.add(const ProductsEvent.searchProducts('test1'));
bloc.add(const ProductsEvent.searchProducts('test2'));
bloc.add(const ProductsEvent.searchProducts('test3'));
},
wait: const Duration(milliseconds: 400),
expect: () => [
const ProductsState.loading(),
ProductsState.loaded(
products: tProducts,
searchQuery: 'test3',
currentPage: 1,
),
],
verify: (_) {
// Solo debe llamar una vez debido al debounce
verify(() => mockGetProductsUseCase(
category: null,
searchQuery: 'test3',
page: 1,
limit: 20,
)).called(1);
},
);
});
}
๐ฏ Mejores Prรกcticas
1. Event Naming
โ DO:
const factory ProductsEvent.loadProducts() = ProductsLoadRequested;
const factory ProductsEvent.refreshProducts() = ProductsRefreshRequested;
โ DON'T:
const factory ProductsEvent.load() = LoadProducts; // Poco descriptivo
const factory ProductsEvent.getProducts() = GetProducts; // Usa verbos de UI
2. State Naming y Estructura
โ DO:
@freezed
class ProductsState with _$ProductsState {
const factory ProductsState.initial() = ProductsInitial;
const factory ProductsState.loading() = ProductsLoading;
const factory ProductsState.loaded({
required List<Product> products,
@Default(false) bool hasReachedMax,
}) = ProductsLoaded;
const factory ProductsState.error(String message) = ProductsError;
}
โ DON'T:
// No uses un solo estado con flags
class ProductsState {
final List<Product> products;
final bool isLoading;
final bool hasError;
final String? errorMessage;
}
3. Uso de Transformers
โ DO:
on<ProductsSearchRequested>(
_onSearchRequested,
transformer: debounce(const Duration(milliseconds: 300)),
);
on<ProductsLoadRequested>(
_onLoadRequested,
transformer: restartable(), // Cancela eventos anteriores
);
4. Separaciรณn de Concerns
โ DO:
// BLoC solo coordina
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
final GetProductsUseCase getProductsUseCase; // Use case hace el trabajo
Future<void> _onLoadRequested(...) async {
final result = await getProductsUseCase(); // Delega al use case
// ... maneja resultado
}
}
โ DON'T:
// BLoC con lรณgica de negocio acoplada
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
final Dio dio;
Future<void> _onLoadRequested(...) async {
final response = await dio.get('/products'); // โ Lรณgica de API aquรญ
final products = (response.data as List).map(...).toList(); // โ Parsing aquรญ
}
}
5. Testing Exhaustivo
โ DO:
// Usa bloc_test para tests claros y concisos
blocTest<ProductsBloc, ProductsState>(
'emits [loading, loaded] when successful',
build: () => ProductsBloc(getProductsUseCase: mockUseCase),
act: (bloc) => bloc.add(const ProductsEvent.loadProducts()),
expect: () => [
const ProductsState.loading(),
ProductsState.loaded(products: tProducts),
],
);
6. Manejo de Streams y Subscriptions
โ DO:
class NotificationsBloc extends Bloc<NotificationsEvent, NotificationsState> {
final NotificationService _notificationService;
StreamSubscription<Notification>? _notificationSubscription;
NotificationsBloc(this._notificationService) : super(...) {
on<NotificationsStarted>(_onStarted);
on<NotificationsReceived>(_onReceived);
}
Future<void> _onStarted(...) async {
await _notificationSubscription?.cancel();
_notificationSubscription = _notificationService.stream.listen(
(notification) => add(NotificationsEvent.received(notification)),
);
}
@override
Future<void> close() {
_notificationSubscription?.cancel();
return super.close();
}
}
๐ Recursos Adicionales
๐ Skills Relacionados
- Clean Architecture - Arquitectura completa con BLoC
- Testing Strategy - Testing de BLoCs
- Riverpod - Alternativa a BLoC
Versiรณn: 1.0.0
รltima actualizaciรณn: Diciembre 2025