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
|
||||
});
|
||||
137
lib/main.dart
137
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,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 {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user