Overview
The Adapters Layer (also called Infrastructure Layer) implements the repository interfaces defined in the domain layer and provides connections to external services like Firebase, email, and authentication. Location:lib/adaptadores/
lib/adaptadores/
├── adaptador_firestore_reserva.dart
├── adaptador_firestore_mesa.dart
├── adaptador_firestore_negocio.dart
├── adaptador_firestore_horario.dart
├── adaptador_firestore_historia.dart
├── servicio_email.dart
├── servicio_autenticacion_firebase.dart
└── servicio_verificacion_cliente.dart
Firestore Adapters
ReservaRepositorioFirestore
Implements reservation data access with Firestore.lib/adaptadores/adaptador_firestore_reserva.dart
class ReservaRepositorioFirestore implements ReservaRepositorio {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
CollectionReference<Map<String, dynamic>> get _reservasRef =>
_firestore.collection('reservas');
@override
Future<Reserva> crearReserva(Reserva reserva) async {
try {
final docRef = await _reservasRef.add(_reservaToMap(reserva));
print('✅ Reserva creada en Firestore: ${docRef.id}');
return reserva.copyWith(id: docRef.id);
} catch (e) {
print('❌ Error creando reserva: $e');
rethrow;
}
}
@override
Future<bool> mesaDisponible({
required String mesaId,
required DateTime fecha,
required DateTime hora,
required int duracionMinutos,
}) async {
try {
// Get all reservations for this table on this date
final reservas = await obtenerReservasPorMesaYHorario(
mesaId: mesaId,
fecha: fecha,
hora: hora,
);
// Calculate the interval for the new reservation
final horaInicioNueva = hora;
final horaFinNueva = hora.add(Duration(minutes: duracionMinutos));
// Check for time collisions with existing reservations
for (final reserva in reservas) {
final horaInicioExistente = reserva.fechaHora;
final horaFinExistente = reserva.horaFin;
// Collision if intervals overlap
final hayColision = horaInicioNueva.isBefore(horaFinExistente) &&
horaFinNueva.isAfter(horaInicioExistente);
if (hayColision) {
return false;
}
}
return true;
} catch (e) {
print('❌ Error verificando disponibilidad: $e');
return false;
}
}
@override
Future<List<Reserva>> obtenerReservasPorMesaYHorario({
required String mesaId,
required DateTime fecha,
required DateTime hora,
}) async {
try {
// Search reservations for the same day
final inicioDia = DateTime(fecha.year, fecha.month, fecha.day);
final finDia = inicioDia.add(const Duration(days: 1));
final snapshot = await _reservasRef
.where('mesaId', isEqualTo: mesaId)
.where('fechaHora', isGreaterThanOrEqualTo: Timestamp.fromDate(inicioDia))
.where('fechaHora', isLessThan: Timestamp.fromDate(finDia))
.get();
return snapshot.docs
.map((doc) => _mapToReserva(doc.id, doc.data()))
.where((r) => r.estado != EstadoReserva.cancelada)
.toList();
} catch (e) {
print('❌ Error obteniendo reservas: $e');
return [];
}
}
// ============================================================
// CONVERSION METHODS
// ============================================================
Map<String, dynamic> _reservaToMap(Reserva reserva) {
return {
'mesaId': reserva.mesaId,
'fechaHora': Timestamp.fromDate(reserva.fechaHora),
'numeroPersonas': reserva.numeroPersonas,
'duracionMinutos': reserva.duracionMinutos,
'estado': _estadoToString(reserva.estado),
'contactoCliente': reserva.contactoCliente,
'nombreCliente': reserva.nombreCliente,
'telefonoCliente': reserva.telefonoCliente,
'negocioId': reserva.negocioId,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
};
}
Reserva _mapToReserva(String id, Map<String, dynamic> data) {
return Reserva(
id: id,
mesaId: data['mesaId'] ?? '',
fechaHora: (data['fechaHora'] as Timestamp).toDate(),
numeroPersonas: data['numeroPersonas'] ?? 1,
duracionMinutos: data['duracionMinutos'] ?? 60,
estado: _stringToEstado(data['estado']),
contactoCliente: data['contactoCliente'],
nombreCliente: data['nombreCliente'],
telefonoCliente: data['telefonoCliente'],
negocioId: data['negocioId'],
);
}
String _estadoToString(EstadoReserva estado) {
switch (estado) {
case EstadoReserva.pendiente:
return 'pendiente';
case EstadoReserva.confirmada:
return 'confirmada';
case EstadoReserva.cancelada:
return 'cancelada';
}
}
EstadoReserva _stringToEstado(String? estado) {
switch (estado) {
case 'confirmada':
return EstadoReserva.confirmada;
case 'cancelada':
return EstadoReserva.cancelada;
default:
return EstadoReserva.pendiente;
}
}
}
Key Pattern: Adapters handle conversion between domain entities and external data formats. The domain never knows about Firestore
Timestamp or FieldValue.Time Collision Detection Algorithm
The availability check uses interval overlap detection:Existing Reservation: |-----60min-----|
New Reservation: |-----60min-----|
│ │
Overlap! ❌
Condition: horaInicioNueva < horaFinExistente && horaFinNueva > horaInicioExistente
MesaRepositorioFirestore
Implements table management with smart availability search.lib/adaptadores/adaptador_firestore_mesa.dart
class MesaRepositorioFirestore implements MesaRepositorio {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final ReservaRepositorio _reservaRepositorio;
MesaRepositorioFirestore({required ReservaRepositorio reservaRepositorio})
: _reservaRepositorio = reservaRepositorio;
@override
Future<Mesa?> buscarMesaDisponibleEnZona({
required String zona,
required DateTime fecha,
required DateTime hora,
required int numeroPersonas,
required String negocioId,
}) async {
try {
final mesas = await obtenerMesasPorNegocio(negocioId);
// Filter tables in the zone that can accommodate the group
final mesasZona = mesas
.where((m) => m.zona == zona && m.puedeAcomodar(numeroPersonas))
.toList();
// Sort by capacity (prioritize best fit)
mesasZona.sort((a, b) => a.capacidad.compareTo(b.capacidad));
// Find first available table
for (final mesa in mesasZona) {
final disponible = await _reservaRepositorio.mesaDisponible(
mesaId: mesa.id,
fecha: fecha,
hora: hora,
duracionMinutos: 60,
);
if (disponible) {
return mesa;
}
}
return null;
} catch (e) {
print('❌ Error buscando mesa en zona: $e');
return null;
}
}
}
The adapter crosses layer boundaries by depending on
ReservaRepositorio to check availability. This is acceptable because both are in the adapters layer.Services
ServicioEmail
Handles all email notifications using Firebase Trigger Email Extension.lib/adaptadores/servicio_email.dart
class ServicioEmail {
/// Sends confirmation email to customer
Future<void> enviarReservaConfirmada({
required String emailCliente,
required String nombreCliente,
required String nombreNegocio,
required DateTime fechaHora,
required String nombreMesa,
required int numeroPersonas,
}) async {
final fecha = _formatearFecha(fechaHora);
final hora = _formatearHora(fechaHora);
final html = _wrapTemplate(
titulo: '✅ Reserva Confirmada',
contenido: '''
<h2>Tu reserva ha sido confirmada</h2>
<p>Hola $nombreCliente, tu reserva está confirmada.</p>
${_buildDetallesReserva(
nombreCliente: nombreCliente,
nombreNegocio: nombreNegocio,
nombreMesa: nombreMesa,
fecha: fecha,
hora: hora,
personas: numeroPersonas,
)}
<div style="background: #E8F5E9; padding: 15px;">
<strong>✅ Estado:</strong> Confirmada
</div>
''',
);
await _enviarEmail(
to: emailCliente,
subject: '✅ Reserva confirmada - $nombreNegocio',
html: html,
);
}
/// Sends cancellation email when customer cancels
Future<void> enviarReservaCanceladaPorCliente({ /* ... */ }) async {
// Similar structure
}
/// Sends cancellation email when restaurant cancels
Future<void> enviarReservaCanceladaPorRestaurante({
required String motivo,
// ...
}) async {
// Includes cancellation reason
}
/// Notifies restaurant owner of new reservation
Future<void> enviarNuevaReservaAlDueno({ /* ... */ }) async {
// Email to restaurant owner
}
// ============================================================
// FIREBASE TRIGGER EMAIL
// ============================================================
Future<void> _enviarEmail({
required String to,
required String subject,
required String html,
}) async {
try {
// Add document to Firestore - Firebase Extension processes it
final docRef = await FirebaseFirestore.instance.collection('mail').add({
'to': [to], // Extension expects array
'message': {
'subject': subject,
'html': html,
},
'createdAt': FieldValue.serverTimestamp(),
});
print('✅ Email agregado a cola de envío: ${docRef.id}');
} catch (e) {
print('❌ Error al agregar email a Firestore: $e');
rethrow;
}
}
}
Emails are sent asynchronously by the Firebase Trigger Email Extension. The app doesn’t wait for actual delivery, only for the document to be added to the
mail collection.ServicioAutenticacion
Handles Firebase Authentication for restaurant owners.lib/adaptadores/servicio_autenticacion_firebase.dart
class ServicioAutenticacion {
final FirebaseAuth _auth = FirebaseAuth.instance;
GoogleSignIn? _googleSignIn;
Stream<User?> get authStateChanges => _auth.authStateChanges();
User? get usuarioActual => _auth.currentUser;
bool get estaAutenticado => _auth.currentUser != null;
/// Register with email/password
Future<UserCredential?> registrarConEmail({
required String email,
required String password,
}) async {
try {
final userCredential = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
// Send verification email automatically
if (userCredential.user != null) {
await enviarEmailVerificacion();
}
return userCredential;
} on FirebaseAuthException catch (e) {
throw _manejarError(e);
}
}
/// Sign in with email/password
Future<UserCredential?> iniciarSesionConEmail({
required String email,
required String password,
}) async {
try {
return await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
throw _manejarError(e);
}
}
/// Sign in with Google
Future<UserCredential?> iniciarSesionConGoogle() async {
if (kIsWeb) {
// Web: Use popup
final googleProvider = GoogleAuthProvider();
googleProvider.setCustomParameters({'prompt': 'select_account'});
return await _auth.signInWithPopup(googleProvider);
} else {
// Mobile: Use google_sign_in package
final GoogleSignInAccount? googleUser = await googleSignIn.signIn();
if (googleUser == null) return null;
final GoogleSignInAuthentication googleAuth =
await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
return await _auth.signInWithCredential(credential);
}
}
/// Sign out
Future<void> cerrarSesion() async {
await _auth.signOut();
if (!kIsWeb && _googleSignIn != null) {
await _googleSignIn!.signOut();
}
}
/// Send SMS verification code
Future<bool> enviarCodigoSMS({
required String numeroTelefono,
required Function(String) onCodeSent,
required Function(String) onError,
required Function() onAutoVerified,
}) async {
try {
final phoneNumber = _formatearNumeroTelefono(numeroTelefono);
await _auth.verifyPhoneNumber(
phoneNumber: phoneNumber,
timeout: const Duration(seconds: 60),
verificationCompleted: (PhoneAuthCredential credential) async {
// Auto-verification (Android)
try {
final user = _auth.currentUser;
if (user != null) {
await user.linkWithCredential(credential);
onAutoVerified();
}
} catch (e) {
onError('Error en verificación automática: $e');
}
},
verificationFailed: (FirebaseAuthException e) {
String mensaje;
switch (e.code) {
case 'invalid-phone-number':
mensaje = 'Número inválido. Usa formato: +54 9 11 1234-5678';
break;
case 'too-many-requests':
mensaje = 'Demasiados intentos. Intenta más tarde';
break;
default:
mensaje = 'Error: ${e.message}';
}
onError(mensaje);
},
codeSent: (String verificationId, int? resendToken) {
_verificationId = verificationId;
_resendToken = resendToken;
onCodeSent('Código enviado por SMS');
},
codeAutoRetrievalTimeout: (String verificationId) {
_verificationId = verificationId;
},
);
return true;
} catch (e) {
onError('Error al enviar SMS: $e');
return false;
}
}
/// Verify SMS code
Future<void> verificarCodigoSMS({required String codigo}) async {
if (_verificationId == null) {
throw Exception('Primero debes solicitar el código SMS');
}
try {
final credential = PhoneAuthProvider.credential(
verificationId: _verificationId!,
smsCode: codigo,
);
final user = _auth.currentUser;
if (user == null) {
throw Exception('No hay usuario autenticado');
}
await user.updatePhoneNumber(credential);
_verificationId = null;
_resendToken = null;
} on FirebaseAuthException catch (e) {
switch (e.code) {
case 'invalid-verification-code':
throw Exception('Código incorrecto');
case 'credential-already-in-use':
throw Exception('Este número ya está vinculado a otra cuenta');
default:
throw Exception('Error: ${e.message}');
}
}
}
/// Format phone number to E.164 format
/// Example: "11 1234-5678" → "+5491112345678"
String _formatearNumeroTelefono(String numero) {
String limpio = numero.replaceAll(RegExp(r'[\s\-\(\)]'), '');
if (!limpio.startsWith('+')) {
if (limpio.startsWith('0')) limpio = limpio.substring(1);
if (limpio.startsWith('15')) limpio = '9${limpio.substring(2)}';
if (!limpio.startsWith('9') && limpio.length == 10) limpio = '9$limpio';
limpio = '+54$limpio';
}
return limpio;
}
}
Phone number formatting is crucial for Firebase Auth. The service handles Argentine phone numbers, converting formats like “11 1234-5678” to E.164 format “+5491112345678”.
Data Conversion Patterns
Entity to Map (for Firestore)
Map<String, dynamic> _entityToMap(Entity entity) {
return {
'field1': entity.field1,
'dateField': Timestamp.fromDate(entity.dateField),
'enumField': _enumToString(entity.enumField),
'createdAt': FieldValue.serverTimestamp(),
};
}
Map to Entity (from Firestore)
Entity _mapToEntity(String id, Map<String, dynamic> data) {
return Entity(
id: id,
field1: data['field1'] ?? 'default',
dateField: (data['dateField'] as Timestamp).toDate(),
enumField: _stringToEnum(data['enumField']),
);
}
Enum Conversion
String _enumToString(MyEnum value) {
switch (value) {
case MyEnum.value1:
return 'value1';
case MyEnum.value2:
return 'value2';
}
}
MyEnum _stringToEnum(String? value) {
switch (value) {
case 'value1':
return MyEnum.value1;
case 'value2':
return MyEnum.value2;
default:
return MyEnum.value1; // Default
}
}
Firestore Collections
Firestore Database
├── reservas/
│ └── {reservaId}
│ ├── mesaId: string
│ ├── fechaHora: timestamp
│ ├── numeroPersonas: number
│ ├── duracionMinutos: number
│ ├── estado: string
│ ├── contactoCliente: string
│ ├── nombreCliente: string
│ ├── telefonoCliente: string
│ ├── negocioId: string
│ ├── createdAt: timestamp
│ └── updatedAt: timestamp
├── mesas/
│ └── {mesaId}
│ ├── nombre: string
│ ├── capacidad: number
│ ├── negocioId: string
│ └── zona: string
├── negocios/
│ └── {negocioId}
│ ├── nombre: string
│ ├── email: string
│ ├── minHorasParaCancelar: number
│ ├── maxDiasAnticipacionReserva: number
│ └── ...
└── mail/ (Firebase Trigger Email Extension)
└── {emailId}
├── to: array
├── message: map
└── delivery: map (added by extension)
Error Handling
Adapters handle infrastructure errors gracefully:try {
// Firestore operation
final result = await _firestore.collection('...').doc('...').get();
return _mapToEntity(result);
} catch (e) {
print('❌ Error en adaptador: $e');
// Options:
// 1. Return null/empty for queries
return null;
// 2. Rethrow for critical operations
rethrow;
// 3. Return default value
return [];
}
Testing Adapters
Adapters can be tested with:- Firebase Emulator (integration tests)
- Mock Firestore (unit tests)
test('ReservaRepositorioFirestore creates reservation', () async {
// Setup Firebase emulator
final firestore = FirebaseFirestore.instance;
firestore.useFirestoreEmulator('localhost', 8080);
final repo = ReservaRepositorioFirestore();
final reserva = Reserva(/* ... */);
final created = await repo.crearReserva(reserva);
expect(created.id, isNotEmpty);
// Verify in Firestore
final doc = await firestore.collection('reservas').doc(created.id).get();
expect(doc.exists, isTrue);
});
Summary
Implement Interfaces
Adapters implement domain repository interfaces
Handle External APIs
Connect to Firebase, email services, authentication
Convert Data
Transform between domain entities and external formats
Graceful Errors
Handle infrastructure failures without crashing
Next Steps
Domain Layer
See the interfaces these adapters implement
Presentation Layer
Learn how the UI consumes these adapters through use cases