feat: add WebSocket support - real-time notifications in Flutter
This commit is contained in:
86
lib/core/ws_provider.dart
Normal file
86
lib/core/ws_provider.dart
Normal 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
|
||||||
|
});
|
||||||
155
lib/main.dart
155
lib/main.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'core/config/supabase_config.dart';
|
import 'core/config/supabase_config.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'core/ws_provider.dart';
|
||||||
import 'features/recycling_guide/presentation/screens/recycling_guide_screen.dart';
|
import 'features/recycling_guide/presentation/screens/recycling_guide_screen.dart';
|
||||||
import 'features/routes/presentation/screens/routes_home_screen.dart';
|
import 'features/routes/presentation/screens/routes_home_screen.dart';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class MyApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Basura App - Notificación de Residuos',
|
title: 'Basura App - Notificación de Residuos',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
home: const HomePage(),
|
home: const LoginScreen(),
|
||||||
routes: {
|
routes: {
|
||||||
'/guia': (context) => const RecyclingGuideScreen(),
|
'/guia': (context) => const RecyclingGuideScreen(),
|
||||||
'/rutas': (context) => const RoutesHomeScreen(),
|
'/rutas': (context) => const RoutesHomeScreen(),
|
||||||
@@ -34,28 +35,92 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const HomePage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Login')),
|
||||||
title: const Text('Basura App'),
|
body: Padding(
|
||||||
centerTitle: true,
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
TextField(
|
||||||
onPressed: () => Navigator.pushNamed(context, '/guia'),
|
controller: emailCtrl,
|
||||||
child: const Text('📚 Guía de Reciclaje'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pushNamed(context, '/rutas'),
|
onPressed: loading ? null : login,
|
||||||
child: const Text('🚚 Rutas & ETA'),
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies:
|
|||||||
supabase_flutter: ^2.5.0
|
supabase_flutter: ^2.5.0
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
dio: ^5.3.1
|
dio: ^5.3.1
|
||||||
|
web_socket_channel: ^2.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.core.config import settings
|
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.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion
|
||||||
from app.services.ws_manager import ws_manager
|
from app.services.ws_manager import ws_manager
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class SimuladorRuta:
|
|||||||
def __init__(self, route_id: str, tick_segundos: int = None):
|
def __init__(self, route_id: str, tick_segundos: int = None):
|
||||||
self.route_id = route_id
|
self.route_id = route_id
|
||||||
self.tick = tick_segundos or settings.sim_tick_seconds
|
self.tick = tick_segundos or settings.sim_tick_seconds
|
||||||
self.repo = SQLiteRutaRepository()
|
self.repo = SupabaseRutaRepository()
|
||||||
self._tarea: Optional[asyncio.Task] = None
|
self._tarea: Optional[asyncio.Task] = None
|
||||||
self._corriendo = False
|
self._corriendo = False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user