From f3df1760774a5e7c9fc0e52a8b3c845aaaaed8f1 Mon Sep 17 00:00:00 2001 From: hack_23031391_8ff9d8 <23031391@itcelaya.edu.mx> Date: Sat, 23 May 2026 02:58:40 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=2002-frontend=5Farquitectura.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 02-frontend_arquitectura.md.-.md | 498 +++++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 02-frontend_arquitectura.md.-.md diff --git a/02-frontend_arquitectura.md.-.md b/02-frontend_arquitectura.md.-.md new file mode 100644 index 0000000..6a3e8cd --- /dev/null +++ b/02-frontend_arquitectura.md.-.md @@ -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> 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>.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> get stream => _controller.stream; +} +``` + +### Providers con Riverpod + +```dart +// lib/features/eta/presentation/providers/eta_provider.dart +final etaProvider = FutureProvider.family((ref, addressId) async { + final repo = ref.watch(etaRepositoryProvider); + return repo.getETA(addressId); +}); + +// WebSocket real-time +final wsProvider = StreamProvider.family( + (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 items; + + List get itemsAceptados => + items.where((i) => i.acepta).toList(); + + List 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? _cache; + + Future> cargarCategorias() async { + if (_cache != null) return _cache!; + + final raw = await rootBundle.loadString('assets/recycling_guide.json'); + final List json = jsonDecode(raw); + + _cache = json.map(_mapearCategoria).toList(); + return _cache!; + } +} +``` + +### Búsqueda + +```dart +// lib/features/recycling_guide/data/repositories/recycling_repository.dart +Future> buscar(String query) async { + final q = query.toLowerCase(); + final categorias = await obtenerCategorias(); + final resultados = []; + + 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 results) = _Done; +} + +final recyclingSearchProvider = + StateNotifierProvider((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((ref) { + return 'http://localhost:8000'; // Cambiar en producción +}); + +final dioProvider = Provider((ref) { + final baseUrl = ref.watch(apiBaseUrlProvider); + return Dio(BaseOptions(baseUrl: baseUrl)); +}); +``` + +### FutureProvider para datos remotos + +```dart +final userAddressesProvider = FutureProvider>((ref) async { + final repo = ref.watch(addressRepositoryProvider); + return repo.getUserAddresses(); +}); +``` + +### StreamProvider para WebSocket + +```dart +final notificationStreamProvider = StreamProvider((ref) { + final ws = ref.watch(wsDataSourceProvider); + return ws.stream; +}); +``` + +### StateNotifierProvider para búsqueda local + +```dart +final recyclingSearchProvider = + StateNotifierProvider( + (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)