skills/flutter/bloc-advanced/SKILL.md
# 🎨 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](https://bloclibrary.dev/) | ## 🔑 Keywords para Invocación Usa cualquiera de estos keywords en tus prompts para invocar este skill: - `bloc` - `cubit` - `flutter-bloc` -
npx skillsauth add altrupets/monorepo skills/flutter/bloc-advancedInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
| 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 |
Usa cualquiera de estos keywords en tus prompts para invocar este skill:
bloccubitflutter-blocbloc-advancedhydrated-bloc@skill:bloc-advancedCrea 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
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.
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
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
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(),
),
);
},
),
);
}
}
// 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}');
}
}
// 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,
);
}
}
// 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;
}
// 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;
}
// 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);
}
// 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;
}
// 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,
);
}
}
// 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);
},
),
);
},
),
),
);
}
}
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),
),
);
}
}
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(),
),
),
);
}
}
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),
);
},
),
),
);
}
}
// 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);
},
);
});
}
✅ 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
✅ 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;
}
✅ DO:
on<ProductsSearchRequested>(
_onSearchRequested,
transformer: debounce(const Duration(milliseconds: 300)),
);
on<ProductsLoadRequested>(
_onLoadRequested,
transformer: restartable(), // Cancela eventos anteriores
);
✅ 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í
}
}
✅ 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),
],
);
✅ 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();
}
}
Versión: 1.0.0 Última actualización: Diciembre 2025
development
# 🔧 Skill: Native Integration (Swift/Kotlin) ## 📋 Metadata | Atributo | Valor | |----------|-------| | **ID** | `flutter-native-integration` | | **Nivel** | 🔴 Avanzado | | **Versión** | 1.0.0 | | **Keywords** | `native-integration`, `swift`, `kotlin`, `uikit`, `android-sdk`, `native-ui` | | **Referencia** | [Flutter Platform Integration](https://docs.flutter.dev/platform-integration) | ## 🔑 Keywords para Invocación Usa cualquiera de estos keywords en tus prompts para invocar este skill:
testing
# 🎨 Skill: MVVM Pattern ## 📋 Metadata | Atributo | Valor | |----------|-------| | **ID** | `flutter-mvvm-pattern` | | **Nivel** | 🟡 Intermedio | | **Versión** | 1.0.0 | | **Keywords** | `mvvm`, `model-view-viewmodel`, `provider`, `changenotifier` | ## 🔑 Keywords para Invocación Usa cualquiera de estos keywords en tus prompts para invocar este skill: - `mvvm` - `model-view-viewmodel` - `provider` - `changenotifier` - `@skill:mvvm` ### Ejemplos de Prompts ``` Crea una app de lista de ta
development
# 🎨 Skill: Arquitectura Modular ## 📋 Metadata | Atributo | Valor | |----------|-------| | **ID** | `flutter-modular-architecture` | | **Nivel** | 🔴 Avanzado | | **Versión** | 1.0.0 | | **Keywords** | `modular`, `modular-architecture`, `module`, `multi-module` | | **Referencia** | [Flutter Modular Package](https://pub.dev/packages/flutter_modular) | ## 🔑 Keywords para Invocación Usa cualquiera de estos keywords en tus prompts para invocar este skill: - `modular` - `modular-architecture`
tools
# 📱 Skill: Mobile Testing y Debugging con Flutter MCP ## 📋 Metadata | Atributo | Valor | |----------|-------| | **ID** | `flutter-mobile-testing` | | **Nivel** | 🔴 Avanzado | | **Versión** | 1.2.0 | | **Keywords** | `mobile-testing`, `integration-test`, `flutter-mcp`, `dart-mcp`, `debugging`, `logic-analysis`, `widget-inspection`, `device-testing` | | **Referencia** | [Dart and Flutter MCP server](https://docs.flutter.dev/ai/mcp-server) \| [Mobile MCP](https://github.com/mobile-next/mobile-