CULTIVA IA Web › Flutter › Arquitectura por Capas

App Mis Herramientas
Flutter MVVM + Repository

Implementación completa de la arquitectura en capas (UI / Dominio / Datos) para la feature de catálogo de herramientas IA, siguiendo la guía oficial de Flutter.

UI Layer · MVVM · ChangeNotifier Domain Layer · freezed models Data Layer · Repository Pattern Flutter 3 · Dart 3 · get_it · provider

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

Clase Capa Scope get_it Motivo
ToolsApiService Data Singleton Stateless, reutiliza http.Client
LocalStorageService Data Singleton Acceso único a SharedPreferences
ToolsRepository Data Singleton Mantiene caché en memoria
ToggleFavoriteUseCase Domain Singleton Stateless, inyecta repo ya existente
ToolsViewModel UI Factory Nueva instancia por ruta/widget

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
8
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