Arquitectura en Capas
🎨 UI Layer (Presentación)
MVVM — widgets sin lógica de negocio, ViewModels que extienden ChangeNotifier
ToolsView
ToolDetailView
ToolsViewModel
ToolDetailViewModel
ToolCard (widget)
FilterBar (widget)
⬇ consume modelos de dominio ⬆ expone estado inmutable
🧠 Domain Layer (Lógica)
Modelos inmutables (freezed) y Use Cases de lógica compleja · fuente única de verdad
AiTool (model)
ToolCategory (enum)
ToolsState (sealed)
ToggleFavoriteUseCase
⬇ llama services ⬆ devuelve domain models
💾 Data Layer (Repositorios + Servicios)
Repository pattern — caching, modo offline, transformación de modelos API → dominio
ToolsRepository
ToolsApiService
LocalStorageService
ToolApiModel
Estructura de Carpetas
lib/
├── data/
│ ├── models/
│ │ └── tool_api_model.dart // modelos raw de la API
│ ├── repositories/
│ │ └── tools_repository.dart // caché + transformación
│ └── services/
│ ├── tools_api_service.dart // cliente HTTP
│ └── local_storage_service.dart // favoritos persistentes
│
├── domain/
│ ├── models/
│ │ ├── ai_tool.dart // modelo inmutable (freezed)
│ │ ├── tool_category.dart // enum ToolCategory
│ │ └── tools_state.dart // sealed class Loading/Success/Error
│ └── use_cases/
│ └── toggle_favorite_use_case.dart // lógica favoritos cross-VM
│
├── ui/
│ ├── core/
│ │ ├── app_theme.dart
│ │ └── widgets/ // ToolCard, FilterBar, EmptyState
│ └── features/
│ ├── tools_list/
│ │ ├── view_models/
│ │ │ └── tools_view_model.dart
│ │ └── views/
│ │ └── tools_view.dart
│ └── tool_detail/
│ ├── view_models/
│ │ └── tool_detail_view_model.dart
│ └── views/
│ └── tool_detail_view.dart
│
├── di/
│ └── injection.dart // get_it setup
└── main.dart
Domain Domain Layer — Modelos + Use Case
1. Modelo de Dominio — ai_tool.dart
lib/domain/models/ai_tool.dart
Domain
// Modelo inmutable generado con freezed
@freezed
class AiTool with _$AiTool {
const AiTool._();
const factory AiTool({
required String id,
required String nombre,
required String descripcion,
required ToolCategory categoria,
required double puntuacion, // 0.0 – 5.0
required String precio, // "gratis" | "freemium" | "€29/mes"
@Default(false) bool isFavorite,
}) = _AiTool;
/// Comodidad: ¿es una herramienta de alto valor?
bool get isTopRated => puntuacion >= 4.5;
}
enum ToolCategory {
marketing, web, automatizacion, contenido, datos, agentes;
String get label => switch (this) {
ToolCategory.marketing => 'Marketing',
ToolCategory.web => 'Web',
ToolCategory.automatizacion => 'Automatización',
ToolCategory.contenido => 'Contenido',
ToolCategory.datos => 'Datos',
ToolCategory.agentes => 'Agentes IA',
};
}
2. Estado sellado — tools_state.dart
lib/domain/models/tools_state.dart
Domain
/// Estado sellado para la lista de herramientas
sealed class ToolsState {
const ToolsState();
}
final class ToolsLoading extends ToolsState {
const ToolsLoading();
}
final class ToolsLoaded extends ToolsState {
const ToolsLoaded({
required this.tools,
this.activeCategory,
});
final List<AiTool> tools;
final ToolCategory? activeCategory;
List<AiTool> get filtered => activeCategory == null
? tools
: tools.where((t) => t.categoria == activeCategory).toList();
}
final class ToolsError extends ToolsState {
const ToolsError(this.message);
final String message;
}
3. Use Case — toggle_favorite_use_case.dart
lib/domain/use_cases/toggle_favorite_use_case.dart
Domain
/// Use Case: lógica de favoritos reutilizable entre ToolsViewModel
/// y ToolDetailViewModel. Persiste el estado y notifica observadores.
class ToggleFavoriteUseCase {
ToggleFavoriteUseCase({
required ToolsRepository toolsRepository,
}) : _repo = toolsRepository;
final ToolsRepository _repo;
/// Devuelve la herramienta actualizada con el flag invertido
Future<AiTool> call(AiTool tool) async {
final updated = tool.copyWith(isFavorite: !tool.isFavorite);
await _repo.updateFavorite(updated);
return updated;
}
}
Data Data Layer — Service + Repository
4. API Model — tool_api_model.dart
lib/data/models/tool_api_model.dart
Data
/// Modelo crudo que devuelve la API de CULTIVA IA
class ToolApiModel {
const ToolApiModel({
required this.id,
required this.name,
required this.description,
required this.category,
required this.score,
required this.price,
});
final String id, name, description, category, price;
final double score;
factory ToolApiModel.fromJson(Map<String, dynamic> json) => ToolApiModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
category: json['category'] as String,
score: (json['score'] as num).toDouble(),
price: json['price'] as String,
);
}
5. Service — tools_api_service.dart
lib/data/services/tools_api_service.dart
Data
/// Stateless: sólo habla con la API REST de CULTIVA IA.
/// Nunca devuelve domain models; el Repository hace la transformación.
class ToolsApiService {
ToolsApiService({http.Client? client})
: _client = client ?? http.Client();
final http.Client _client;
static const _base = 'https://api.cultivaia.com/v1';
Future<List<ToolApiModel>> fetchTools() async {
final response = await _client.get(Uri.parse('$_base/tools'));
if (response.statusCode != 200) {
throw ApiException('Error ${response.statusCode}: no se pudo cargar el catálogo');
}
final list = jsonDecode(response.body) as List;
return list.map((j) => ToolApiModel.fromJson(j as Map<String, dynamic>)).toList();
}
}
6. Repository — tools_repository.dart
lib/data/repositories/tools_repository.dart
Data
/// Fuente única de verdad. Gestiona caché en memoria + favoritos en disco.
class ToolsRepository {
ToolsRepository({
required ToolsApiService apiService,
required LocalStorageService storage,
}) : _api = apiService,
_storage = storage;
final ToolsApiService _api;
final LocalStorageService _storage;
List<AiTool>? _cache;
Future<List<AiTool>> getTools({bool forceRefresh = false}) async {
if (!forceRefresh && _cache != null) return _cache!;
final favoriteIds = await _storage.getFavoriteIds();
final rawModels = await _api.fetchTools();
_cache = rawModels.map((m) => AiTool(
id: m.id,
nombre: m.name,
descripcion: m.description,
categoria: ToolCategory.values.byName(m.category),
puntuacion: m.score,
precio: m.price,
isFavorite: favoriteIds.contains(m.id),
)).toList();
return _cache!;
}
Future<void> updateFavorite(AiTool tool) async {
final idx = _cache?.indexWhere((t) => t.id == tool.id) ?? -1;
if (idx != -1) _cache![idx] = tool;
await _storage.setFavorite(tool.id, isFavorite: tool.isFavorite);
}
}
UI UI Layer — ViewModel + View
7. ViewModel — tools_view_model.dart
lib/ui/features/tools_list/view_models/tools_view_model.dart
UI
/// Extiende ChangeNotifier. Sólo expone estado inmutable a la View.
/// La View NUNCA accede a Repository directamente.
class ToolsViewModel extends ChangeNotifier {
ToolsViewModel({
required ToolsRepository repository,
required ToggleFavoriteUseCase toggleFavorite,
}) : _repo = repository,
_toggleFavorite = toggleFavorite;
final ToolsRepository _repo;
final ToggleFavoriteUseCase _toggleFavorite;
ToolsState _state = const ToolsLoading();
ToolsState get state => _state;
Future<void> loadTools() async {
_state = const ToolsLoading();
notifyListeners();
try {
final tools = await _repo.getTools();
_state = ToolsLoaded(tools: tools);
} catch (e) {
_state = ToolsError('No se pudo cargar el catálogo: $e');
} finally {
notifyListeners();
}
}
Future<void> toggleFavorite(AiTool tool) async {
final updated = await _toggleFavorite(tool);
if (_state is ToolsLoaded) {
final current = (_state as ToolsLoaded);
final newList = current.tools.map((t) => t.id == updated.id ? updated : t).toList();
_state = ToolsLoaded(tools: newList, activeCategory: current.activeCategory);
notifyListeners();
}
}
void filterByCategory(ToolCategory? category) {
if (_state is ToolsLoaded) {
_state = (_state as ToolsLoaded).copyWith(activeCategory: category);
notifyListeners();
}
}
}
8. View — tools_view.dart
lib/ui/features/tools_list/views/tools_view.dart
UI
/// Widget "tonto": sólo renderiza. Toda la lógica vive en ToolsViewModel.
class ToolsView extends StatefulWidget {
const ToolsView({super.key, required this.viewModel});
final ToolsViewModel viewModel;
@override
State<ToolsView> createState() => _ToolsViewState();
}
class _ToolsViewState extends State<ToolsView> {
@override
void initState() {
super.initState();
widget.viewModel.loadTools();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Mis Herramientas IA')),
body: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) => switch (widget.viewModel.state) {
ToolsLoading() => const Center(child: CircularProgressIndicator()),
ToolsError(:var message) => ErrorState(
message: message,
onRetry: widget.viewModel.loadTools,
),
ToolsLoaded(:var filtered, :var activeCategory) => Column(
children: [
FilterBar(
selected: activeCategory,
onSelected: widget.viewModel.filterByCategory,
),
Expanded(
child: GridView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => ToolCard(
tool: filtered[i],
onFavoriteToggle: () => widget.viewModel.toggleFavorite(filtered[i]),
),
),
),
],
),
},
),
);
}
}
DI Inyección de Dependencias — get_it
lib/di/injection.dart
DI
final GetIt sl = GetIt.instance;
Future<void> configureDependencies() async {
// ── Servicios (singleton: una sola instancia en toda la app) ──────────
sl.registerLazySingleton<ToolsApiService>(
() => ToolsApiService(),
);
sl.registerLazySingleton<LocalStorageService>(
() => LocalStorageService(),
);
// ── Repositorios (singleton) ──────────────────────────────────────────
sl.registerLazySingleton<ToolsRepository>(
() => ToolsRepository(
apiService: sl<ToolsApiService>(),
storage: sl<LocalStorageService>(),
),
);
// ── Use Cases (singleton) ─────────────────────────────────────────────
sl.registerLazySingleton<ToggleFavoriteUseCase>(
() => ToggleFavoriteUseCase(toolsRepository: sl<ToolsRepository>()),
);
// ── ViewModels (factory: nueva instancia por pantalla) ────────────────
sl.registerFactory<ToolsViewModel>(
() => ToolsViewModel(
repository: sl<ToolsRepository>(),
toggleFavorite: sl<ToggleFavoriteUseCase>(),
),
);
}
// main.dart — arranque
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
runApp(const CultivaApp());
}
Registro de dependencias
Checklist de Implementación — Feature "Mis Herramientas"
Step 1 — Definir Domain Models
Modelos inmutables con freezed, enum de categorías y sealed class de estado.
ai_tool.dart · tool_category.dart · tools_state.dart
Step 2 — Implementar Services
ToolsApiService (HTTP REST) y LocalStorageService (favoritos persistentes).
tools_api_service.dart · local_storage_service.dart
Step 3 — Implementar Repository
ToolsRepository con caché en memoria, modo offline y transformación API → Domain.
tools_repository.dart
Step 4 — Domain Layer (Use Case)
ToggleFavoriteUseCase: lógica de favoritos reutilizable entre ToolsViewModel y ToolDetailViewModel.
toggle_favorite_use_case.dart
Step 5 — Implementar ViewModel
ToolsViewModel extiende ChangeNotifier, expone ToolsState inmutable, delega en Use Case.
tools_view_model.dart
Step 6 — Implementar View
ToolsView usa ListenableBuilder con pattern matching sobre ToolsState sealed class.
tools_view.dart
Step 7 — Inyección de Dependencias
get_it configurado con singletons (servicios/repos) y factory para ViewModels.
di/injection.dart
Step 8 — Ejecutar Tests
flutter test · ViewModel tests con mock repository · Repository tests con mock service.
test/tools_view_model_test.dart · test/tools_repository_test.dart