1
02-frontend_arquitectura.md
hack_23031391_8ff9d8 edited this page 2026-05-23 02:58:40 +00:00

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

// 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:

// 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:

// 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

// 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

// 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

// 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

// 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

// 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

// 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

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

// 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

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

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

Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => RecyclingGuideScreen()),
);

Gestión de estado — Riverpod patterns

Provider de configuración global

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

final userAddressesProvider = FutureProvider<List<Address>>((ref) async {
  final repo = ref.watch(addressRepositoryProvider);
  return repo.getUserAddresses();
});

StreamProvider para WebSocket

final notificationStreamProvider = StreamProvider<NotificationEvent>((ref) {
  final ws = ref.watch(wsDataSourceProvider);
  return ws.stream;
});

StateNotifierProvider para búsqueda local

final recyclingSearchProvider = 
    StateNotifierProvider<RecyclingSearchNotifier, RecyclingSearchState>(
  (ref) => RecyclingSearchNotifier(ref.watch(recyclingRepositoryProvider))
);

Testing

Unit test del repositorio

// 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

// 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: