diff --git a/lib/core/ws_provider.dart b/lib/core/ws_provider.dart new file mode 100644 index 0000000..2176a4e --- /dev/null +++ b/lib/core/ws_provider.dart @@ -0,0 +1,86 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +// Estado del WebSocket +class WSState { + final bool connected; + final String? error; + final dynamic lastMessage; + + const WSState({ + this.connected = false, + this.error, + this.lastMessage, + }); + + WSState copyWith({ + bool? connected, + String? error, + dynamic lastMessage, + }) { + return WSState( + connected: connected ?? this.connected, + error: error, + lastMessage: lastMessage ?? this.lastMessage, + ); + } +} + +class WSNotifier extends StateNotifier { + WebSocketChannel? _channel; + final String baseUrl; + + WSNotifier(this.baseUrl) : super(const WSState()); + + // Conectar a WebSocket de una dirección + Future connect(String token, int addressId) async { + try { + final wsUrl = baseUrl.replaceFirst('https', 'wss').replaceFirst('http', 'ws'); + final url = Uri.parse('$wsUrl/eta/ws/$addressId'); + + _channel = WebSocketChannel.connect(url); + + // Escuchar mensajes + _channel?.stream.listen( + (message) { + state = state.copyWith( + lastMessage: message, + connected: true, + error: null, + ); + }, + onError: (error) { + state = state.copyWith( + connected: false, + error: error.toString(), + ); + }, + onDone: () { + state = state.copyWith(connected: false); + }, + ); + + state = state.copyWith(connected: true, error: null); + } catch (e) { + state = state.copyWith(connected: false, error: e.toString()); + } + } + + // Enviar mensaje + void send(String message) { + if (_channel != null && state.connected) { + _channel!.sink.add(message); + } + } + + // Desconectar + void disconnect() { + _channel?.sink.close(status.goingAway); + state = state.copyWith(connected: false); + } +} + +final wsProvider = StateNotifierProvider((ref) { + return WSNotifier('http://localhost:8000'); // Cambiar a URL de producción +}); diff --git a/lib/main.dart b/lib/main.dart index 0afa17c..af22068 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'core/config/supabase_config.dart'; import 'core/theme/app_theme.dart'; +import 'core/ws_provider.dart'; import 'features/recycling_guide/presentation/screens/recycling_guide_screen.dart'; import 'features/routes/presentation/screens/routes_home_screen.dart'; @@ -25,7 +26,7 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Basura App - Notificación de Residuos', theme: AppTheme.lightTheme, - home: const HomePage(), + home: const LoginScreen(), routes: { '/guia': (context) => const RecyclingGuideScreen(), '/rutas': (context) => const RoutesHomeScreen(), @@ -34,28 +35,92 @@ class MyApp extends StatelessWidget { } } -class HomePage extends StatelessWidget { - const HomePage({super.key}); +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final emailCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + bool loading = false; + String? error; + + @override + void dispose() { + emailCtrl.dispose(); + passCtrl.dispose(); + super.dispose(); + } + + Future login() async { + setState(() { + loading = true; + error = null; + }); + + try { + final response = await Supabase.instance.client + .from('users') + .select('id, email, phone, password_hash') + .eq('email', emailCtrl.text) + .single(); + + // Verificar password (en producción, hacer en backend) + // Por ahora: login demo + if (response['email'] == emailCtrl.text) { + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomePage()), + ); + } + } catch (e) { + setState(() => error = 'Email o contraseña incorrectos'); + } finally { + setState(() => loading = false); + } + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Basura App'), - centerTitle: true, - ), - body: Center( + appBar: AppBar(title: const Text('Login')), + body: Padding( + padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () => Navigator.pushNamed(context, '/guia'), - child: const Text('📚 Guía de Reciclaje'), + TextField( + controller: emailCtrl, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), ), const SizedBox(height: 16), + TextField( + controller: passCtrl, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + if (error != null) + Text(error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), ElevatedButton( - onPressed: () => Navigator.pushNamed(context, '/rutas'), - child: const Text('🚚 Rutas & ETA'), + onPressed: loading ? null : login, + child: loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : const Text('Ingresar'), ), ], ), @@ -63,3 +128,67 @@ class HomePage extends StatelessWidget { ); } } + +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wsState = ref.watch(wsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Basura App'), + centerTitle: true, + actions: [ + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Tooltip( + message: wsState.connected ? 'Conectado' : 'Desconectado', + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: wsState.connected ? Colors.green : Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ], + ), + body: Column( + children: [ + // Estado WebSocket + if (wsState.lastMessage != null) + Container( + color: Colors.blue[100], + padding: const EdgeInsets.all(16), + child: Text('📬 ${wsState.lastMessage}'), + ), + // Botones principales + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => Navigator.pushNamed(context, '/guia'), + child: const Text('📚 Guía de Reciclaje'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.pushNamed(context, '/rutas'), + child: const Text('🚚 Rutas & ETA'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8f4299b..537a371 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: supabase_flutter: ^2.5.0 flutter_riverpod: ^2.6.1 dio: ^5.3.1 + web_socket_channel: ^2.4.0 dev_dependencies: flutter_test: diff --git a/server/app/services/simulador.py b/server/app/services/simulador.py index 61fe898..17b2f0c 100644 --- a/server/app/services/simulador.py +++ b/server/app/services/simulador.py @@ -11,7 +11,7 @@ from datetime import datetime from typing import Optional from app.core.config import settings -from app.data.repositories.ruta_repository import SQLiteRutaRepository +from app.data.repositories.ruta_repository import SupabaseRutaRepository from app.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion from app.services.ws_manager import ws_manager @@ -23,7 +23,7 @@ class SimuladorRuta: def __init__(self, route_id: str, tick_segundos: int = None): self.route_id = route_id self.tick = tick_segundos or settings.sim_tick_seconds - self.repo = SQLiteRutaRepository() + self.repo = SupabaseRutaRepository() self._tarea: Optional[asyncio.Task] = None self._corriendo = False