Añadir 02-frontend_arquitectura.md

2026-05-23 02:58:40 +00:00
parent 219a615cd9
commit f3df176077

@@ -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)