feat: add WebSocket support - real-time notifications in Flutter

This commit is contained in:
Alan Alonso
2026-05-23 01:07:50 -06:00
parent 1886ab6094
commit feead21e73
4 changed files with 231 additions and 15 deletions

86
lib/core/ws_provider.dart Normal file
View File

@@ -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<WSState> {
WebSocketChannel? _channel;
final String baseUrl;
WSNotifier(this.baseUrl) : super(const WSState());
// Conectar a WebSocket de una dirección
Future<void> 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<WSNotifier, WSState>((ref) {
return WSNotifier('http://localhost:8000'); // Cambiar a URL de producción
});

View File

@@ -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,17 +35,142 @@ class MyApp extends StatelessWidget {
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final emailCtrl = TextEditingController();
final passCtrl = TextEditingController();
bool loading = false;
String? error;
@override
void dispose() {
emailCtrl.dispose();
passCtrl.dispose();
super.dispose();
}
Future<void> 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('Login')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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: loading ? null : login,
child: loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(),
)
: const Text('Ingresar'),
),
],
),
),
);
}
}
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: Center(
),
),
),
),
],
),
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: [
@@ -60,6 +186,9 @@ class HomePage extends StatelessWidget {
],
),
),
),
],
),
);
}
}

View File

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

View File

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