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