Agent skill
mvvm
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/mvvm
SKILL.md
🎨 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
Didn't find tool you were looking for?