Skip to main content

Overview

The Restaurant Reservation System uses BLoC (Business Logic Component) pattern with Cubit for state management. This architecture separates business logic from UI, making the code testable, maintainable, and scalable.

Architecture Overview

Why BLoC/Cubit?

Benefits

  • Separation of Concerns: Business logic is separate from UI
  • Testability: Easy to unit test without widgets
  • Reusability: Same logic can be used across multiple screens
  • Predictable State: Unidirectional data flow
  • Reactive: UI automatically updates when state changes

When to Use Cubit vs Bloc

PatternUse CaseExample
CubitSimple state transitionsLoading → Success/Error
BlocComplex event handling with multiple actionsForm validation with multiple fields
This project uses Cubit for simplicity.

Project Structure

lib/
├── presentacion/
│   ├── disponibilidad/
│   │   ├── disponibilidad_cubit.dart           # Business logic
│   │   ├── disponibilidad_estados_de_cubit.dart # State definitions
│   │   └── disponibilidad_screen.dart          # UI
│   ├── pantalla_dueno/
│   │   ├── pantalla_dueno_cubit.dart
│   │   ├── pantalla_dueno_estados_de_cubit.dart
│   │   └── pantalla_dueno_screen.dart
│   └── mis_reservas/
│       ├── mis_reservas_cubit.dart
│       ├── mis_reservas_estados_de_cubit.dart
│       └── mis_reservas_screen.dart
├── aplicacion/                                  # Use cases
│   ├── crear_reserva.dart
│   ├── cancelar_reserva.dart
│   └── obtener_reserva.dart
├── dominio/                                     # Domain entities & repos
│   ├── entidades/
│   └── repositorios/
└── adaptadores/                                 # Infrastructure
    ├── adaptador_firestore_*.dart
    └── servicio_*.dart

Creating a Cubit

Step 1: Define States

lib/presentacion/disponibilidad/disponibilidad_estados_de_cubit.dart
import 'package:flutter/foundation.dart';
import '../../dominio/entidades/mesa.dart';
import '../../dominio/entidades/negocio.dart';

@immutable
abstract class DisponibilidadState {}

class DisponibilidadInicial extends DisponibilidadState {}

class DisponibilidadCargando extends DisponibilidadState {}

class DisponibilidadExitosa extends DisponibilidadState {
  final List<Mesa> mesasDisponibles;
  final Negocio? negocio;
  final Map<String, String>? horariosServicio;

  DisponibilidadExitosa(
    this.mesasDisponibles, {
    this.negocio,
    this.horariosServicio,
  });

  int get duracionPromedioMinutos => negocio?.duracionPromedioMinutos ?? 60;
  int get maxDiasAnticipacionReserva => negocio?.maxDiasAnticipacionReserva ?? 14;
}

class DisponibilidadConError extends DisponibilidadState {
  final String mensaje;
  DisponibilidadConError(this.mensaje);
}

class ReservaCreada extends DisponibilidadState {
  final String mensaje;
  ReservaCreada(this.mensaje);
}

class MesaEncontrada extends DisponibilidadState {
  final Mesa mesa;
  final String zona;
  final int duracionPromedioMinutos;

  MesaEncontrada(this.mesa, this.zona, this.duracionPromedioMinutos);
}

class ProcesandoReserva extends DisponibilidadState {}
Use @immutable to ensure states are immutable and use abstract class for the base state type.

Step 2: Implement the Cubit

lib/presentacion/disponibilidad/disponibilidad_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../aplicacion/crear_reserva.dart';
import '../../dominio/repositorios/mesa_repositorio.dart';
import '../../dominio/repositorios/negocio_repositorio.dart';
import '../../service_locator.dart';
import 'disponibilidad_estados_de_cubit.dart';

class DisponibilidadCubit extends Cubit<DisponibilidadState> {
  final MesaRepositorio _mesaRepositorio;
  final NegocioRepositorio _negocioRepositorio;
  final CrearReserva _crearReserva;

  // Cache business data
  Negocio? _negocioActual;
  Negocio? get negocioActual => _negocioActual;

  DisponibilidadCubit()
    : _mesaRepositorio = getIt<MesaRepositorio>(),
      _negocioRepositorio = getIt<NegocioRepositorio>(),
      _crearReserva = getIt<CrearReserva>(),
      super(DisponibilidadInicial());

  Future<void> cargarTodasLasMesas([String? negocioId]) async {
    try {
      emit(DisponibilidadCargando());
      
      // Load business and tables in parallel
      final resultados = await Future.wait([
        _mesaRepositorio.obtenerMesasPorNegocio(negocioId!),
        _negocioRepositorio.obtenerNegocioPorId(negocioId),
      ]);

      final mesas = resultados[0] as List<Mesa>;
      _negocioActual = resultados[1] as Negocio?;

      emit(DisponibilidadExitosa(mesas, negocio: _negocioActual));
    } catch (e) {
      emit(DisponibilidadConError('Error al cargar: ${e.toString()}'));
    }
  }

  Future<void> buscarMesaEnZona({
    required String zona,
    required DateTime fecha,
    required DateTime hora,
    required int numeroPersonas,
  }) async {
    try {
      emit(DisponibilidadCargando());

      final mesa = await _mesaRepositorio.buscarMesaDisponibleEnZona(
        zona: zona,
        fecha: fecha,
        hora: hora,
        numeroPersonas: numeroPersonas,
        negocioId: _negocioActual?.id ?? 'default',
      );

      if (mesa == null) {
        emit(DisponibilidadConError(
          'No hay mesas disponibles en $zona para $numeroPersonas personas.'
        ));
      } else {
        emit(MesaEncontrada(
          mesa,
          zona,
          _negocioActual?.duracionPromedioMinutos ?? 60,
        ));
      }
    } catch (e) {
      emit(DisponibilidadConError('Error: ${e.toString()}'));
    }
  }

  Future<void> crearReservaVerificadaPorSMS({
    required String emailCliente,
    required String telefonoVerificado,
    required String? nombreCliente,
    required String mesaId,
    required DateTime fecha,
    required DateTime hora,
    required int numeroPersonas,
  }) async {
    try {
      emit(ProcesandoReserva());

      final reserva = await _crearReserva.ejecutar(
        mesaId,
        fecha,
        hora,
        numeroPersonas,
        contactoCliente: emailCliente,
        nombreCliente: nombreCliente,
        telefonoCliente: telefonoVerificado,
        estadoInicial: EstadoReserva.confirmada,
        negocioId: _negocioActual?.id ?? 'default',
      );

      emit(ReservaCreada(
        '✅ Reserva confirmada. Recibirás los detalles por email.',
      ));
    } catch (e) {
      emit(DisponibilidadConError('Error: ${e.toString()}'));
    }
  }
}

Step 3: Use in UI

lib/presentacion/disponibilidad/disponibilidad_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'disponibilidad_cubit.dart';
import 'disponibilidad_estados_de_cubit.dart';

class DisponibilidadScreen extends StatelessWidget {
  const DisponibilidadScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => DisponibilidadCubit()..cargarTodasLasMesas(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Disponibilidad')),
        body: BlocConsumer<DisponibilidadCubit, DisponibilidadState>(
          listener: (context, state) {
            if (state is ReservaCreada) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(state.mensaje)),
              );
            } else if (state is DisponibilidadConError) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(state.mensaje),
                  backgroundColor: Colors.red,
                ),
              );
            }
          },
          builder: (context, state) {
            if (state is DisponibilidadCargando) {
              return const Center(child: CircularProgressIndicator());
            }

            if (state is DisponibilidadExitosa) {
              return ListView.builder(
                itemCount: state.mesasDisponibles.length,
                itemBuilder: (context, index) {
                  final mesa = state.mesasDisponibles[index];
                  return ListTile(
                    title: Text(mesa.nombre),
                    subtitle: Text('Capacidad: ${mesa.capacidad}'),
                    onTap: () {
                      // Handle table selection
                    },
                  );
                },
              );
            }

            return const Center(child: Text('Selecciona una fecha'));
          },
        ),
      ),
    );
  }
}

Dependency Injection with GetIt

The app uses GetIt for dependency injection:
lib/service_locator.dart
import 'package:get_it/get_it.dart';
import 'adaptadores/adaptador_firestore_reserva.dart';
import 'adaptadores/servicio_email.dart';
import 'aplicacion/crear_reserva.dart';
import 'dominio/repositorios/reserva_repositorio.dart';

final getIt = GetIt.instance;

void setupServiceLocator() {
  // Prevent duplicate registration on hot reload
  if (getIt.isRegistered<ReservaRepositorio>()) {
    return;
  }

  // Services (singletons)
  getIt.registerLazySingleton<ServicioEmail>(
    () => ServicioEmail(),
  );

  // Repositories (singletons)
  getIt.registerLazySingleton<ReservaRepositorio>(
    () => ReservaRepositorioFirestore(),
  );

  getIt.registerLazySingleton<MesaRepositorio>(
    () => MesaRepositorioFirestore(
      reservaRepositorio: getIt<ReservaRepositorio>(),
    ),
  );

  // Use Cases (factories - new instance each time)
  getIt.registerFactory(
    () => CrearReserva(
      getIt<ReservaRepositorio>(),
      mesaRepositorio: getIt<MesaRepositorio>(),
      servicioEmail: getIt<ServicioEmail>(),
    ),
  );

  // Cubits (factories)
  getIt.registerFactory(
    () => PantallaDuenoCubit(
      getIt<NegocioRepositorio>(),
      getIt<MesaRepositorio>(),
      getIt<ReservaRepositorio>(),
    ),
  );
}

Singleton vs Factory

TypeWhen to UseExample
Singleton (registerLazySingleton)Shared instance across appServicioEmail, ReservaRepositorio
Factory (registerFactory)New instance each timeCrearReserva, Cubits

BLoC Patterns

Pattern 1: Loading → Success/Error

Future<void> loadData() async {
  emit(LoadingState());
  try {
    final data = await repository.fetchData();
    emit(SuccessState(data));
  } catch (e) {
    emit(ErrorState(e.toString()));
  }
}

Pattern 2: Processing → Success/Error

Future<void> processAction() async {
  emit(ProcessingState());
  try {
    await useCase.execute();
    emit(ActionCompletedState('Success!'));
  } catch (e) {
    emit(ErrorState(e.toString()));
  }
}

Pattern 3: Optimistic Updates

Future<void> updateItem(Item item) async {
  // Immediately show updated UI
  emit(UpdatedState(item));
  
  try {
    await repository.update(item);
    // Success - keep the optimistic state
  } catch (e) {
    // Rollback on error
    emit(ErrorState('Failed to update'));
  }
}

BlocBuilder vs BlocListener vs BlocConsumer

BlocBuilder

Use when you only need to rebuild UI based on state:
BlocBuilder<DisponibilidadCubit, DisponibilidadState>(
  builder: (context, state) {
    if (state is DisponibilidadCargando) {
      return CircularProgressIndicator();
    }
    if (state is DisponibilidadExitosa) {
      return ListView(children: [...]);
    }
    return SizedBox();
  },
)

BlocListener

Use when you need side effects (navigation, snackbars) but no UI rebuild:
BlocListener<DisponibilidadCubit, DisponibilidadState>(
  listener: (context, state) {
    if (state is ReservaCreada) {
      Navigator.pop(context);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Reserva creada!')),
      );
    }
  },
  child: MyWidget(),
)

BlocConsumer

Use when you need both UI rebuild and side effects:
BlocConsumer<DisponibilidadCubit, DisponibilidadState>(
  listener: (context, state) {
    // Side effects
    if (state is DisponibilidadConError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.mensaje)),
      );
    }
  },
  builder: (context, state) {
    // UI rebuild
    return MyWidget(state);
  },
)

Testing Cubits

test/disponibilidad_cubit_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';

class MockMesaRepositorio extends Mock implements MesaRepositorio {}

void main() {
  late DisponibilidadCubit cubit;
  late MockMesaRepositorio mockMesaRepo;

  setUp(() {
    mockMesaRepo = MockMesaRepositorio();
    cubit = DisponibilidadCubit(
      mesaRepositorio: mockMesaRepo,
    );
  });

  tearDown(() {
    cubit.close();
  });

  group('DisponibilidadCubit', () {
    test('initial state is DisponibilidadInicial', () {
      expect(cubit.state, isA<DisponibilidadInicial>());
    });

    blocTest<DisponibilidadCubit, DisponibilidadState>(
      'emits [Loading, Success] when cargarTodasLasMesas succeeds',
      build: () {
        when(() => mockMesaRepo.obtenerMesasPorNegocio(any()))
          .thenAnswer((_) async => [Mesa(id: '1', nombre: 'Mesa 1')]);
        return cubit;
      },
      act: (cubit) => cubit.cargarTodasLasMesas('negocio1'),
      expect: () => [
        isA<DisponibilidadCargando>(),
        isA<DisponibilidadExitosa>(),
      ],
    );

    blocTest<DisponibilidadCubit, DisponibilidadState>(
      'emits [Loading, Error] when cargarTodasLasMesas fails',
      build: () {
        when(() => mockMesaRepo.obtenerMesasPorNegocio(any()))
          .thenThrow(Exception('Network error'));
        return cubit;
      },
      act: (cubit) => cubit.cargarTodasLasMesas('negocio1'),
      expect: () => [
        isA<DisponibilidadCargando>(),
        isA<DisponibilidadConError>(),
      ],
    );
  });
}

Best Practices

Never emit states inside build() method. This causes infinite loops!

✅ DO

  • Keep states immutable
  • Use meaningful state names
  • Emit states in async methods
  • Handle all states in UI
  • Close cubits when done
  • Use factories for cubits in GetIt

❌ DON’T

  • Mutate state objects
  • Emit states in build() or initState()
  • Ignore error states
  • Create cubits without disposing them
  • Use singletons for cubits (use factories)

Next Steps

Build docs developers (and LLMs) love