Imported from majiayu000/claude-skill-registry (
skills/development/mvvm-lapc506-flutter-agentic-boil-2/SKILL.md). Install upstream withnpx skills add majiayu000/claude-skill-registry --skill mvvm-lapc506-flutter-agentic-boil-2. Copyright stays with the author.
π¨ 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:
mvvmmodel-view-viewmodelproviderchangenotifier@skill:mvvm
Ejemplos de Prompts
Crea una app de lista de tareas usando mvvm
Implementa model-view-viewmodel para un mΓ³dulo de productos
@skill:mvvm - Genera una app de gestiΓ³n de usuarios con provider
π DescripciΓ³n
El patrΓ³n MVVM (Model-View-ViewModel) proporciona una separaciΓ³n clara entre la lΓ³gica de negocio y la interfaz de usuario, facilitando el testing, mantenimiento y escalabilidad del cΓ³digo.
β οΈ 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
- Proyectos medianos con lΓ³gica de negocio moderada
- Necesitas separaciΓ³n clara entre UI y lΓ³gica
- Quieres testear la lΓ³gica de presentaciΓ³n fΓ‘cilmente
- El equipo estΓ‘ familiarizado con reactive programming
- Necesitas gestiΓ³n de estado reactiva
β CuΓ‘ndo NO Usar Este Skill
- Proyectos muy pequeΓ±os (usa setState)
- Aplicaciones enterprise muy complejas (considera Clean Architecture)
- Necesitas mΓ‘xima separaciΓ³n de capas (usa Clean Architecture)
ποΈ Estructura del Proyecto
lib/
βββ core/
β βββ constants/
β β βββ app_constants.dart
β β βββ api_constants.dart
β β βββ string_constants.dart
β βββ utils/
β β βββ validators.dart
β β βββ formatters.dart
β β βββ helpers.dart
β βββ extensions/
β βββ string_extensions.dart
β βββ date_extensions.dart
β βββ context_extensions.dart
β
βββ models/
β βββ entities/
β β βββ user.dart
β β βββ product.dart
β β βββ order.dart
β βββ dto/
β βββ user_dto.dart
β βββ api_response.dart
β
βββ views/
β βββ screens/
β β βββ home_screen.dart
β β βββ detail_screen.dart
β β βββ settings_screen.dart
β βββ widgets/
β β βββ custom_button.dart
β β βββ custom_card.dart
β β βββ loading_indicator.dart
β βββ dialogs/
β βββ confirmation_dialog.dart
β βββ error_dialog.dart
β
βββ viewmodels/
β βββ home_viewmodel.dart
β βββ detail_viewmodel.dart
β βββ providers/
β βββ app_provider.dart
β
βββ services/
β βββ api/
β β βββ api_service.dart
β β βββ http_client.dart
β βββ storage/
β β βββ local_storage.dart
β β βββ secure_storage.dart
β βββ navigation/
β βββ navigation_service.dart
β
βββ main.dart
π§© Componentes Principales
1. Model (Modelo)
Representa los datos y la lΓ³gica de negocio.
// models/entities/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
String? avatar,
DateTime? createdAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
CaracterΓsticas:
- Inmutabilidad usando
freezed - SerializaciΓ³n JSON automΓ‘tica
- CopyWith para actualizaciones
- Equality por valor
2. View (Vista)
Widgets de Flutter que representan la UI.
// views/screens/user_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class UserListScreen extends StatelessWidget {
const UserListScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Usuarios'),
),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
// Estado de carga
if (viewModel.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
// Estado de error
if (viewModel.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(viewModel.error!),
ElevatedButton(
onPressed: viewModel.fetchUsers,
child: const Text('Reintentar'),
),
],
),
);
}
// Estado exitoso
return ListView.builder(
itemCount: viewModel.users.length,
itemBuilder: (context, index) {
final user = viewModel.users[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: user.avatar != null
? NetworkImage(user.avatar!)
: null,
child: user.avatar == null
? Text(user.name[0])
: null,
),
title: Text(user.name),
subtitle: Text(user.email),
onTap: () => _navigateToDetail(context, user),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToCreate(context),
child: const Icon(Icons.add),
),
);
}
void _navigateToDetail(BuildContext context, User user) {
// NavegaciΓ³n al detalle
}
void _navigateToCreate(BuildContext context) {
// NavegaciΓ³n a creaciΓ³n
}
}
CaracterΓsticas:
- Libre de lΓ³gica de negocio
- Observa cambios con
Consumer - Maneja mΓΊltiples estados (loading, error, success)
- Usa
Selectorpara optimizar rebuilds
3. ViewModel
Maneja la lΓ³gica de presentaciΓ³n y gestiona el estado.
// viewmodels/user_viewmodel.dart
import 'package:flutter/foundation.dart';
class UserViewModel extends ChangeNotifier {
final UserService _userService;
UserViewModel(this._userService);
// Estado
List<User> _users = [];
List<User> get users => List.unmodifiable(_users);
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _error;
String? get error => _error;
User? _selectedUser;
User? get selectedUser => _selectedUser;
// Acciones
Future<void> fetchUsers() async {
_setLoading(true);
_clearError();
try {
_users = await _userService.getUsers();
notifyListeners();
} catch (e) {
_setError('Error al cargar usuarios: ${e.toString()}');
} finally {
_setLoading(false);
}
}
Future<void> createUser(User user) async {
_setLoading(true);
_clearError();
try {
final createdUser = await _userService.createUser(user);
_users.add(createdUser);
notifyListeners();
} catch (e) {
_setError('Error al crear usuario: ${e.toString()}');
} finally {
_setLoading(false);
}
}
Future<void> updateUser(User user) async {
_setLoading(true);
_clearError();
try {
final updatedUser = await _userService.updateUser(user);
final index = _users.indexWhere((u) => u.id == user.id);
if (index != -1) {
_users[index] = updatedUser;
notifyListeners();
}
} catch (e) {
_setError('Error al actualizar usuario: ${e.toString()}');
} finally {
_setLoading(false);
}
}
Future<void> deleteUser(String userId) async {
_setLoading(true);
_clearError();
try {
await _userService.deleteUser(userId);
_users.removeWhere((u) => u.id == userId);
notifyListeners();
} catch (e) {
_setError('Error al eliminar usuario: ${e.toString()}');
} finally {
_setLoading(false);
}
}
void selectUser(User user) {
_selectedUser = user;
notifyListeners();
}
void clearSelection() {
_selectedUser = null;
notifyListeners();
}
// Helpers privados
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
void _setError(String message) {
_error = message;
notifyListeners();
}
void _clearError() {
_error = null;
}
@override
void dispose() {
// Limpiar recursos si es necesario
super.dispose();
}
}
CaracterΓsticas:
- Extiende
ChangeNotifier - Expone estado inmutable
- Maneja errores y loading
- InteractΓΊa con servicios
- Limpia recursos en dispose
4. Service (Servicio)
// services/api/user_service.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
class UserService {
final http.Client _client;
final String _baseUrl;
UserService({
required http.Client client,
required String baseUrl,
}) : _client = client,
_baseUrl = baseUrl;
Future<List<User>> getUsers() async {
final response = await _client.get(
Uri.parse('$_baseUrl/users'),
);
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
return data.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to load users');
}
}
Future<User> createUser(User user) async {
final response = await _client.post(
Uri.parse('$_baseUrl/users'),
headers: {'Content-Type': 'application/json'},
body: json.encode(user.toJson()),
);
if (response.statusCode == 201) {
return User.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to create user');
}
}
Future<User> updateUser(User user) async {
final response = await _client.put(
Uri.parse('$_baseUrl/users/${user.id}'),
headers: {'Content-Type': 'application/json'},
body: json.encode(user.toJson()),
);
if (response.statusCode == 200) {
return User.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to update user');
}
}
Future<void> deleteUser(String userId) async {
final response = await _client.delete(
Uri.parse('$_baseUrl/users/$userId'),
);
if (response.statusCode != 204) {
throw Exception('Failed to delete user');
}
}
}
π¦ Dependencias Recomendadas
name: my_mvvm_app
description: Flutter app with MVVM pattern
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# State Management
provider: ^6.1.1
# Immutability & Serialization
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
# Utilities
equatable: ^2.0.5
# HTTP
http: ^1.1.0
# Local Storage
shared_preferences: ^2.2.2
dev_dependencies:
flutter_test:
sdk: flutter
# Code Generation
build_runner: ^2.4.6
freezed: ^2.4.5
json_serializable: ^6.7.1
# Testing
mockito: ^5.4.4
# Linting
flutter_lints: ^3.0.1
π Flujo de Datos
βββββββββββββββ
β User β
β Interaction β
ββββββββ¬βββββββ
β
βΌ
βββββββββββββββ
β View β
β (Widget) β
ββββββββ¬βββββββ
β Consumer/Selector
βΌ
βββββββββββββββ
β ViewModel β ββββ notifyListeners()
ββββββββ¬βββββββ
β
βΌ
βββββββββββββββ
β Service β
β (API/DB) β
ββββββββ¬βββββββ
β
βΌ
βββββββββββββββ
β Model β
β (Data) β
βββββββββββββββ
π§ͺ Testing
Test del ViewModel
// test/viewmodels/user_viewmodel_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
@GenerateMocks([UserService])
void main() {
late UserViewModel viewModel;
late MockUserService mockService;
setUp(() {
mockService = MockUserService();
viewModel = UserViewModel(mockService);
});
tearDown(() {
viewModel.dispose();
});
group('UserViewModel', () {
test('initial state should be empty', () {
expect(viewModel.users, isEmpty);
expect(viewModel.isLoading, false);
expect(viewModel.error, null);
});
test('fetchUsers should update users list when successful', () async {
// Arrange
final users = [
User(id: '1', name: 'John', email: 'john@test.com'),
User(id: '2', name: 'Jane', email: 'jane@test.com'),
];
when(mockService.getUsers()).thenAnswer((_) async => users);
// Act
await viewModel.fetchUsers();
// Assert
expect(viewModel.users, users);
expect(viewModel.isLoading, false);
expect(viewModel.error, null);
verify(mockService.getUsers()).called(1);
});
test('fetchUsers should set error when service fails', () async {
// Arrange
when(mockService.getUsers()).thenThrow(Exception('Network error'));
// Act
await viewModel.fetchUsers();
// Assert
expect(viewModel.users, isEmpty);
expect(viewModel.isLoading, false);
expect(viewModel.error, isNotNull);
expect(viewModel.error, contains('Network error'));
});
test('createUser should add user to list', () async {
// Arrange
final newUser = User(id: '1', name: 'John', email: 'john@test.com');
when(mockService.createUser(any)).thenAnswer((_) async => newUser);
// Act
await viewModel.createUser(newUser);
// Assert
expect(viewModel.users, contains(newUser));
expect(viewModel.isLoading, false);
});
});
}
Test de Widget
// test/widgets/user_list_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:mockito/mockito.dart';
void main() {
late MockUserViewModel mockViewModel;
setUp(() {
mockViewModel = MockUserViewModel();
});
Widget createWidgetUnderTest() {
return MaterialApp(
home: ChangeNotifierProvider<UserViewModel>.value(
value: mockViewModel,
child: const UserListScreen(),
),
);
}
testWidgets('should show loading indicator when loading', (tester) async {
// Arrange
when(mockViewModel.isLoading).thenReturn(true);
when(mockViewModel.users).thenReturn([]);
when(mockViewModel.error).thenReturn(null);
// Act
await tester.pumpWidget(createWidgetUnderTest());
// Assert
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('should show user list when loaded', (tester) async {
// Arrange
final users = [
User(id: '1', name: 'John', email: 'john@test.com'),
User(id: '2', name: 'Jane', email: 'jane@test.com'),
];
when(mockViewModel.isLoading).thenReturn(false);
when(mockViewModel.users).thenReturn(users);
when(mockViewModel.error).thenReturn(null);
// Act
await tester.pumpWidget(createWidgetUnderTest());
// Assert
expect(find.text('John'), findsOneWidget);
expect(find.text('Jane'), findsOneWidget);
});
testWidgets('should show error message when error occurs', (tester) async {
// Arrange
when(mockViewModel.isLoading).thenReturn(false);
when(mockViewModel.users).thenReturn([]);
when(mockViewModel.error).thenReturn('Network error');
// Act
await tester.pumpWidget(createWidgetUnderTest());
// Assert
expect(find.text('Network error'), findsOneWidget);
expect(find.text('Reintentar'), findsOneWidget);
});
}
β Mejores PrΓ‘cticas
1. Single Responsibility
Cada ViewModel debe manejar una ΓΊnica funcionalidad o pantalla.
2. Dependency Injection
Inyecta servicios a travΓ©s del constructor del ViewModel.
// β Malo
class UserViewModel extends ChangeNotifier {
final service = UserService(); // Acoplamiento fuerte
}
// β
Bueno
class UserViewModel extends ChangeNotifier {
final UserService _service;
UserViewModel(this._service); // InyecciΓ³n de dependencia
}
3. Error Handling Consistente
Maneja errores de forma uniforme en todos los ViewModels.
4. Estado Loading
Siempre indica estados de carga para mejor UX.
5. Dispose Resources
Limpia recursos en el mΓ©todo dispose().
@override
void dispose() {
_streamSubscription?.cancel();
_controller.dispose();
super.dispose();
}
6. Immutability
Usa objetos inmutables para los modelos.
7. OptimizaciΓ³n con Selector
Usa Selector en lugar de Consumer cuando solo necesites parte del estado.
Selector<UserViewModel, bool>(
selector: (context, viewModel) => viewModel.isLoading,
builder: (context, isLoading, child) {
return isLoading
? CircularProgressIndicator()
: child!;
},
child: UserList(),
)
π Recursos Adicionales
π MigraciΓ³n
Desde setState
- Extrae la lΓ³gica de negocio de tus Widgets a ViewModels
- Reemplaza
setState()connotifyListeners() - Envuelve tus Widgets con
ConsumeroSelector
A Clean Architecture
Si tu proyecto crece, considera migrar a Clean Architecture para mayor escalabilidad.
Γltima actualizaciΓ³n: Diciembre 2025 VersiΓ³n: 1.0.0