Añadir 02-frontend_arquitectura.md
498
02-frontend_arquitectura.md.-.md
Normal file
498
02-frontend_arquitectura.md.-.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
# Arquitectura del Frontend (Módulos C + D)
|
||||||
|
|
||||||
|
## Feature-First con Clean Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
basura_app/lib/
|
||||||
|
├── core/
|
||||||
|
│ └── theme/
|
||||||
|
│ └── app_theme.dart # Tema compartido (Persona D)
|
||||||
|
│
|
||||||
|
├── features/
|
||||||
|
│ ├── auth/ # Login (Persona C)
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ └── presentation/
|
||||||
|
│ │ ├── screens/
|
||||||
|
│ │ │ └── login_screen.dart
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ └── auth_provider.dart
|
||||||
|
│ │
|
||||||
|
│ ├── eta/ # Home + WebSocket (Persona C)
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ └── entities/
|
||||||
|
│ │ │ └── eta_info.dart
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ ├── datasources/
|
||||||
|
│ │ │ │ ├── eta_remote_datasource.dart # HTTP
|
||||||
|
│ │ │ │ └── eta_ws_datasource.dart # WebSocket
|
||||||
|
│ │ │ └── repositories/
|
||||||
|
│ │ │ └── eta_repository.dart
|
||||||
|
│ │ └── presentation/
|
||||||
|
│ │ ├── screens/
|
||||||
|
│ │ │ └── eta_home_screen.dart
|
||||||
|
│ │ ├── widgets/
|
||||||
|
│ │ │ ├── eta_card.dart
|
||||||
|
│ │ │ └── notification_banner.dart
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ ├── eta_provider.dart
|
||||||
|
│ │ └── ws_provider.dart
|
||||||
|
│ │
|
||||||
|
│ └── recycling_guide/ # Guía offline (Persona D)
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ └── entities/
|
||||||
|
│ │ └── recycling_category.dart
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── datasources/
|
||||||
|
│ │ │ └── recycling_local_datasource.dart # JSON local
|
||||||
|
│ │ └── repositories/
|
||||||
|
│ │ └── recycling_repository.dart
|
||||||
|
│ └── presentation/
|
||||||
|
│ ├── screens/
|
||||||
|
│ │ ├── recycling_guide_screen.dart
|
||||||
|
│ │ └── category_detail_screen.dart
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── category_card.dart
|
||||||
|
│ │ └── search_result_tile.dart
|
||||||
|
│ └── providers/
|
||||||
|
│ └── recycling_provider.dart
|
||||||
|
│
|
||||||
|
└── main.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo C — ETA + WebSocket (Persona C)
|
||||||
|
|
||||||
|
### Entidades de Dominio
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/eta/domain/entities/eta_info.dart
|
||||||
|
class ETAInfo {
|
||||||
|
final int addressId;
|
||||||
|
final String routeId;
|
||||||
|
final String status; // EN_RUTA | AVERIADA | RETRASADA
|
||||||
|
final int etaMinutos;
|
||||||
|
final Ventana ventana;
|
||||||
|
final String mensaje;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Ventana {
|
||||||
|
final String inicio; // "7:20 pm"
|
||||||
|
final String fin; // "7:35 pm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datasources
|
||||||
|
|
||||||
|
**HTTP REST:**
|
||||||
|
```dart
|
||||||
|
// lib/features/eta/data/datasources/eta_remote_datasource.dart
|
||||||
|
class ETARemoteDatasource {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getETA(int addressId) async {
|
||||||
|
final response = await _dio.get('/eta/$addressId');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**WebSocket:**
|
||||||
|
```dart
|
||||||
|
// lib/features/eta/data/datasources/eta_ws_datasource.dart
|
||||||
|
class ETAWebSocketDatasource {
|
||||||
|
IOWebSocketChannel? _channel;
|
||||||
|
final _controller = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
|
||||||
|
void connect(int addressId) {
|
||||||
|
_channel = IOWebSocketChannel.connect(
|
||||||
|
'ws://localhost:8000/ws/$addressId'
|
||||||
|
);
|
||||||
|
_channel!.stream.listen((data) {
|
||||||
|
final json = jsonDecode(data);
|
||||||
|
_controller.add(json);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Map<String, dynamic>> get stream => _controller.stream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Providers con Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/eta/presentation/providers/eta_provider.dart
|
||||||
|
final etaProvider = FutureProvider.family<ETAInfo, int>((ref, addressId) async {
|
||||||
|
final repo = ref.watch(etaRepositoryProvider);
|
||||||
|
return repo.getETA(addressId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket real-time
|
||||||
|
final wsProvider = StreamProvider.family<NotificationEvent, int>(
|
||||||
|
(ref, addressId) {
|
||||||
|
final datasource = ref.watch(wsDataSourceProvider);
|
||||||
|
datasource.connect(addressId);
|
||||||
|
return datasource.stream.map(_mapToEvent);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pantalla principal
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/eta/presentation/screens/eta_home_screen.dart
|
||||||
|
class ETAHomeScreen extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final addressId = 1; // Del usuario autenticado
|
||||||
|
|
||||||
|
// Initial HTTP fetch
|
||||||
|
final etaAsync = ref.watch(etaProvider(addressId));
|
||||||
|
|
||||||
|
// WebSocket real-time updates
|
||||||
|
ref.listen(wsProvider(addressId), (prev, next) {
|
||||||
|
next.when(
|
||||||
|
data: (event) => _showNotification(context, event),
|
||||||
|
error: (e, _) => _showError(context),
|
||||||
|
loading: () {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text('¿Cuándo llega el camión?')),
|
||||||
|
body: etaAsync.when(
|
||||||
|
loading: () => CircularProgressIndicator(),
|
||||||
|
error: (e, _) => ErrorWidget(e),
|
||||||
|
data: (eta) => ETACard(eta: eta),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo D — Guía de Residuos (Persona D)
|
||||||
|
|
||||||
|
### Características clave
|
||||||
|
|
||||||
|
✅ **Completamente offline** — no depende del backend
|
||||||
|
✅ **Búsqueda en tiempo real** — filtra por nombre y ejemplos
|
||||||
|
✅ **4 categorías:** orgánicos, reciclables, sanitarios, especiales
|
||||||
|
✅ **Cache en memoria** — el JSON se lee una sola vez
|
||||||
|
|
||||||
|
### Entidades
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/recycling_guide/domain/entities/recycling_category.dart
|
||||||
|
class RecyclingCategory {
|
||||||
|
final String id;
|
||||||
|
final String nombre;
|
||||||
|
final String descripcion;
|
||||||
|
final String colorHex;
|
||||||
|
final String icono;
|
||||||
|
final String consejo;
|
||||||
|
final List<RecyclingItem> items;
|
||||||
|
|
||||||
|
List<RecyclingItem> get itemsAceptados =>
|
||||||
|
items.where((i) => i.acepta).toList();
|
||||||
|
|
||||||
|
List<RecyclingItem> get itemsRechazados =>
|
||||||
|
items.where((i) => !i.acepta).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecyclingItem {
|
||||||
|
final String nombre;
|
||||||
|
final String ejemplos;
|
||||||
|
final bool acepta; // true = sí va aquí, false = NO va
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datasource Local
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/recycling_guide/data/datasources/recycling_local_datasource.dart
|
||||||
|
class RecyclingLocalDatasource {
|
||||||
|
List<RecyclingCategory>? _cache;
|
||||||
|
|
||||||
|
Future<List<RecyclingCategory>> cargarCategorias() async {
|
||||||
|
if (_cache != null) return _cache!;
|
||||||
|
|
||||||
|
final raw = await rootBundle.loadString('assets/recycling_guide.json');
|
||||||
|
final List<dynamic> json = jsonDecode(raw);
|
||||||
|
|
||||||
|
_cache = json.map(_mapearCategoria).toList();
|
||||||
|
return _cache!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Búsqueda
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/recycling_guide/data/repositories/recycling_repository.dart
|
||||||
|
Future<List<SearchResult>> buscar(String query) async {
|
||||||
|
final q = query.toLowerCase();
|
||||||
|
final categorias = await obtenerCategorias();
|
||||||
|
final resultados = <SearchResult>[];
|
||||||
|
|
||||||
|
for (final cat in categorias) {
|
||||||
|
for (final item in cat.items) {
|
||||||
|
final coincide = item.nombre.toLowerCase().contains(q) ||
|
||||||
|
item.ejemplos.toLowerCase().contains(q);
|
||||||
|
if (coincide) {
|
||||||
|
resultados.add(SearchResult(categoria: cat, item: item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultados;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider con estado sellado
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/recycling_guide/presentation/providers/recycling_provider.dart
|
||||||
|
sealed class RecyclingSearchState {
|
||||||
|
const RecyclingSearchState();
|
||||||
|
|
||||||
|
const factory RecyclingSearchState.idle() = _Idle;
|
||||||
|
const factory RecyclingSearchState.loading() = _Loading;
|
||||||
|
const factory RecyclingSearchState.done(List<SearchResult> results) = _Done;
|
||||||
|
}
|
||||||
|
|
||||||
|
final recyclingSearchProvider =
|
||||||
|
StateNotifierProvider<RecyclingSearchNotifier, RecyclingSearchState>((ref) {
|
||||||
|
return RecyclingSearchNotifier(ref.watch(recyclingRepositoryProvider));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pantalla con buscador
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/features/recycling_guide/presentation/screens/recycling_guide_screen.dart
|
||||||
|
class RecyclingGuideScreen extends ConsumerStatefulWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Guía de separación'),
|
||||||
|
actions: [
|
||||||
|
// Badge "sin internet"
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.offline_bolt, size: 14),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text('sin internet', style: TextStyle(fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Buscador
|
||||||
|
TextField(
|
||||||
|
onChanged: (query) =>
|
||||||
|
ref.read(recyclingSearchProvider.notifier).buscar(query),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '¿Dónde va el aceite? ¿y la pila?',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Resultados o categorías
|
||||||
|
Expanded(child: _buscando ? _SearchResults() : _CategoryList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tema compartido (Persona D → usado por Persona C)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/theme/app_theme.dart
|
||||||
|
class AppTheme {
|
||||||
|
static const primaryColor = Color(0xFF1B5E20); // verde oscuro
|
||||||
|
static const organicosColor = Color(0xFF4CAF50);
|
||||||
|
static const reciclabesColor = Color(0xFF2196F3);
|
||||||
|
static const sanitariosColor = Color(0xFFFF5722);
|
||||||
|
static const especialesColor = Color(0xFFFF9800);
|
||||||
|
|
||||||
|
static ThemeData get lightTheme => ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: primaryColor),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
// ... más configuración
|
||||||
|
);
|
||||||
|
|
||||||
|
static Color colorDeCategoriaId(String id) {
|
||||||
|
return switch (id) {
|
||||||
|
'organicos' => organicosColor,
|
||||||
|
'reciclables' => reciclabesColor,
|
||||||
|
'sanitarios' => sanitariosColor,
|
||||||
|
'especiales' => especialesColor,
|
||||||
|
_ => primaryColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aplicar en main.dart
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/main.dart
|
||||||
|
void main() {
|
||||||
|
runApp(ProviderScope(child: MyApp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Basura App',
|
||||||
|
theme: AppTheme.lightTheme, // ← Tema de Persona D
|
||||||
|
home: ETAHomeScreen(), // ← Persona C
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navegación (Persona C decide la estrategia)
|
||||||
|
|
||||||
|
### Opción A: go_router (recomendado)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, __) => ETAHomeScreen()),
|
||||||
|
GoRoute(path: '/guia', builder: (_, __) => RecyclingGuideScreen()),
|
||||||
|
GoRoute(path: '/login', builder: (_, __) => LoginScreen()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// En MaterialApp
|
||||||
|
MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
theme: AppTheme.lightTheme,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción B: Navigator tradicional
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => RecyclingGuideScreen()),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestión de estado — Riverpod patterns
|
||||||
|
|
||||||
|
### Provider de configuración global
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final apiBaseUrlProvider = Provider<String>((ref) {
|
||||||
|
return 'http://localhost:8000'; // Cambiar en producción
|
||||||
|
});
|
||||||
|
|
||||||
|
final dioProvider = Provider<Dio>((ref) {
|
||||||
|
final baseUrl = ref.watch(apiBaseUrlProvider);
|
||||||
|
return Dio(BaseOptions(baseUrl: baseUrl));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### FutureProvider para datos remotos
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final userAddressesProvider = FutureProvider<List<Address>>((ref) async {
|
||||||
|
final repo = ref.watch(addressRepositoryProvider);
|
||||||
|
return repo.getUserAddresses();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### StreamProvider para WebSocket
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final notificationStreamProvider = StreamProvider<NotificationEvent>((ref) {
|
||||||
|
final ws = ref.watch(wsDataSourceProvider);
|
||||||
|
return ws.stream;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### StateNotifierProvider para búsqueda local
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final recyclingSearchProvider =
|
||||||
|
StateNotifierProvider<RecyclingSearchNotifier, RecyclingSearchState>(
|
||||||
|
(ref) => RecyclingSearchNotifier(ref.watch(recyclingRepositoryProvider))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit test del repositorio
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// test/features/recycling_guide/data/repositories/recycling_repository_test.dart
|
||||||
|
void main() {
|
||||||
|
test('buscar retorna resultados correctos', () async {
|
||||||
|
final repo = RecyclingRepository();
|
||||||
|
final resultados = await repo.buscar('pila');
|
||||||
|
|
||||||
|
expect(resultados.length, greaterThan(0));
|
||||||
|
expect(resultados.first.categoria.id, equals('especiales'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Widget test
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// test/features/recycling_guide/presentation/screens/recycling_guide_screen_test.dart
|
||||||
|
void main() {
|
||||||
|
testWidgets('muestra 4 categorías', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp(home: RecyclingGuideScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Orgánicos'), findsOneWidget);
|
||||||
|
expect(find.text('Reciclables'), findsOneWidget);
|
||||||
|
expect(find.text('Sanitarios'), findsOneWidget);
|
||||||
|
expect(find.text('Especiales'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Siguiente paso
|
||||||
|
|
||||||
|
Lee los contratos de integración:
|
||||||
|
- [Endpoints REST](../api/01-endpoints.md)
|
||||||
|
- [WebSocket protocol](../api/02-websocket.md)
|
||||||
|
- [Integración entre módulos](../api/04-integracion.md)
|
||||||
Reference in New Issue
Block a user