fix: add project contents
This commit is contained in:
88
HackOnLinces_app/aplicacion_hack/lib/firebase_options.dart
Normal file
88
HackOnLinces_app/aplicacion_hack/lib/firebase_options.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
return web;
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
return macos;
|
||||
case TargetPlatform.windows:
|
||||
return windows;
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions web = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDfpYR_--oRlGdTjLnAZY6z3RLh3LLz5gk',
|
||||
appId: '1:338042609701:web:5f0a245f364e7604371fcc',
|
||||
messagingSenderId: '338042609701',
|
||||
projectId: 'hackon-58b23',
|
||||
authDomain: 'hackon-58b23.firebaseapp.com',
|
||||
storageBucket: 'hackon-58b23.firebasestorage.app',
|
||||
measurementId: 'G-TBT47P2HX5',
|
||||
);
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyBuT70SbADLeg92ll8keCySI8I4eYyqyLw',
|
||||
appId: '1:338042609701:android:0f0f92d7f895794f371fcc',
|
||||
messagingSenderId: '338042609701',
|
||||
projectId: 'hackon-58b23',
|
||||
storageBucket: 'hackon-58b23.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAB7czRjHfrkHEjiDOvPLiUz9zS1UGiZbw',
|
||||
appId: '1:338042609701:ios:d1ac418d368d1df5371fcc',
|
||||
messagingSenderId: '338042609701',
|
||||
projectId: 'hackon-58b23',
|
||||
storageBucket: 'hackon-58b23.firebasestorage.app',
|
||||
iosBundleId: 'com.example.aplicacionHack',
|
||||
);
|
||||
|
||||
static const FirebaseOptions macos = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAB7czRjHfrkHEjiDOvPLiUz9zS1UGiZbw',
|
||||
appId: '1:338042609701:ios:d1ac418d368d1df5371fcc',
|
||||
messagingSenderId: '338042609701',
|
||||
projectId: 'hackon-58b23',
|
||||
storageBucket: 'hackon-58b23.firebasestorage.app',
|
||||
iosBundleId: 'com.example.aplicacionHack',
|
||||
);
|
||||
|
||||
static const FirebaseOptions windows = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDfpYR_--oRlGdTjLnAZY6z3RLh3LLz5gk',
|
||||
appId: '1:338042609701:web:b01c28c419fddd25371fcc',
|
||||
messagingSenderId: '338042609701',
|
||||
projectId: 'hackon-58b23',
|
||||
authDomain: 'hackon-58b23.firebaseapp.com',
|
||||
storageBucket: 'hackon-58b23.firebasestorage.app',
|
||||
measurementId: 'G-DYGG6KBJ6C',
|
||||
);
|
||||
}
|
||||
114
HackOnLinces_app/aplicacion_hack/lib/main.dart
Normal file
114
HackOnLinces_app/aplicacion_hack/lib/main.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
// ================================================================
|
||||
// main.dart — EcoTrack
|
||||
// Sistema de Notificacion Privada de Recoleccion de Residuos
|
||||
// ================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/route_list_screen.dart';
|
||||
import 'screens/info_screen.dart';
|
||||
import 'screens/analytics_screen.dart';
|
||||
import 'screens/reporte_screen.dart';
|
||||
import 'screens/mapa_rutas_screen.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
const AndroidNotificationChannel _canal = AndroidNotificationChannel(
|
||||
'ecotrack_canal',
|
||||
'EcoTrack Notificaciones',
|
||||
description: 'Notificaciones del camión de basura',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
// DESPUÉS
|
||||
final androidPlugin =
|
||||
_localNotifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
await androidPlugin?.createNotificationChannel(_canal);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
),
|
||||
);
|
||||
|
||||
// ← EL QUE TE FALTABA
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
final notification = message.notification;
|
||||
if (notification == null) return;
|
||||
_localNotifications.show(
|
||||
notification.hashCode,
|
||||
notification.title,
|
||||
notification.body,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_canal.id,
|
||||
_canal.name,
|
||||
channelDescription: _canal.description,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await FirebaseMessaging.instance.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
runApp(const EcoTrackApp());
|
||||
}
|
||||
|
||||
class EcoTrackApp extends StatelessWidget {
|
||||
const EcoTrackApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'EcoTrack',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF2E7D32),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'Roboto',
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (context) => const LoginScreen(),
|
||||
'/home': (context) => const HomeScreen(),
|
||||
'/routes': (context) => const RouteListScreen(),
|
||||
'/info': (context) => const InfoScreen(),
|
||||
'/analytics': (context) => const AnalyticsScreen(),
|
||||
'/reporte': (context) => const ReporteScreen(),
|
||||
'/mapa': (context) => const MapaRutasScreen(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1092
HackOnLinces_app/aplicacion_hack/lib/screens/analytics_screen.dart
Normal file
1092
HackOnLinces_app/aplicacion_hack/lib/screens/analytics_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
927
HackOnLinces_app/aplicacion_hack/lib/screens/home_screen.dart
Normal file
927
HackOnLinces_app/aplicacion_hack/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,927 @@
|
||||
// ================================================================
|
||||
// lib/screens/home_screen.dart (v3 — rediseño visual)
|
||||
// ================================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
int? _usuarioId;
|
||||
String _nombreUsuario = '';
|
||||
ETAInfo? _etaInfo;
|
||||
List<DireccionInfo> _direcciones = [];
|
||||
List<String> _colonias = [];
|
||||
bool _cargando = true;
|
||||
bool _cargandoColonias = true;
|
||||
String? _error;
|
||||
String? _errorColonias;
|
||||
|
||||
Timer? _refreshTimer;
|
||||
final TextEditingController _nuevaDireccionController = TextEditingController();
|
||||
String? _nuevaColoniaSeleccionada;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _fadeController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.06).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_fadeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
);
|
||||
_fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_usuarioId == null) {
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
if (args is int) {
|
||||
_usuarioId = args;
|
||||
_inicializar();
|
||||
} else {
|
||||
_cargarDesdeStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
_fadeController.dispose();
|
||||
_refreshTimer?.cancel();
|
||||
_nuevaDireccionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _inicializar() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) setState(() => _nombreUsuario = prefs.getString('nombre') ?? '');
|
||||
_cargarETA();
|
||||
_iniciarAutoRefresh();
|
||||
_registrarFCMToken();
|
||||
_cargarUsuario();
|
||||
_cargarColonias();
|
||||
}
|
||||
|
||||
Future<void> _cargarDesdeStorage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt('usuario_id');
|
||||
if (id != null) {
|
||||
_usuarioId = id;
|
||||
_inicializar();
|
||||
} else {
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cargarETA() async {
|
||||
if (!mounted) return;
|
||||
setState(() { _cargando = true; _error = null; });
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final usuarioId = prefs.getInt('usuario_id') ?? 1;
|
||||
final etaInfo = await _apiService.obtenerETA(usuarioId);
|
||||
if (mounted) {
|
||||
setState(() { _etaInfo = etaInfo; _cargando = false; });
|
||||
_fadeController.forward(from: 0);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_etaInfo = ETAInfo(
|
||||
usuarioId: 1,
|
||||
colonia: "Centro",
|
||||
rutaNombre: "Ruta Poniente - Camión #4",
|
||||
rutaStatus: "EN_RUTA",
|
||||
gpsOk: true,
|
||||
etaTexto: "12 minutos aprox.",
|
||||
etaMinutos: 12,
|
||||
mensajePreventivo: "⚠️ El camión está a 3 cuadras. ¡Prepara tus bolsas!",
|
||||
);
|
||||
_cargando = false;
|
||||
});
|
||||
_fadeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cargarUsuario() async {
|
||||
if (_usuarioId == null) return;
|
||||
try {
|
||||
final usuario = await _apiService.obtenerUsuario(_usuarioId!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_direcciones = usuario.direcciones;
|
||||
if (usuario.nombre.isNotEmpty) _nombreUsuario = usuario.nombre;
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('nombre', usuario.nombre);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error cargando usuario: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cargarColonias() async {
|
||||
try {
|
||||
final colonias = await _apiService.obtenerColonias();
|
||||
if (mounted) setState(() { _colonias = colonias; _cargandoColonias = false; });
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_colonias = ['Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes'];
|
||||
_cargandoColonias = false;
|
||||
_errorColonias = 'Sin conexión. Usando lista local.';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _iniciarAutoRefresh() {
|
||||
_refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _cargarETA());
|
||||
}
|
||||
|
||||
Future<void> _registrarFCMToken() async {
|
||||
try {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
final settings = await messaging.requestPermission(alert: true, sound: true, badge: true);
|
||||
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
|
||||
final token = await messaging.getToken();
|
||||
if (token != null && _usuarioId != null) {
|
||||
await _apiService.registrarFcmToken(_usuarioId!, token);
|
||||
}
|
||||
}
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
if (message.notification != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('🚛 ${message.notification!.body}'),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
duration: const Duration(seconds: 5),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
_cargarETA();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error FCM: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── DIÁLOGOS ────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _confirmarCerrarSesion() async {
|
||||
final confirmar = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Cerrar sesión'),
|
||||
content: const Text('¿Seguro que quieres cerrar sesión?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancelar')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
child: const Text('Cerrar sesión', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmar == true) {
|
||||
_refreshTimer?.cancel();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mostrarCambioPassword() async {
|
||||
final actualCtrl = TextEditingController();
|
||||
final nuevoCtrl = TextEditingController();
|
||||
bool mostrarActual = false;
|
||||
bool mostrarNuevo = false;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setS) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Cambiar contraseña'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: actualCtrl,
|
||||
obscureText: !mostrarActual,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Contraseña actual',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(mostrarActual ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setS(() => mostrarActual = !mostrarActual),
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nuevoCtrl,
|
||||
obscureText: !mostrarNuevo,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nueva contraseña',
|
||||
hintText: 'Mínimo 6 caracteres',
|
||||
prefixIcon: const Icon(Icons.lock_reset),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(mostrarNuevo ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setS(() => mostrarNuevo = !mostrarNuevo),
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancelar')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (_usuarioId == null) return;
|
||||
if (actualCtrl.text.isEmpty || nuevoCtrl.text.isEmpty) return;
|
||||
if (nuevoCtrl.text.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mínimo 6 caracteres.')));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _apiService.actualizarPassword(_usuarioId!, actualCtrl.text, nuevoCtrl.text);
|
||||
if (mounted) {
|
||||
Navigator.of(ctx).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('✅ Contraseña actualizada.'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.red.shade700),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
actualCtrl.dispose();
|
||||
nuevoCtrl.dispose();
|
||||
}
|
||||
|
||||
Future<void> _agregarDireccion() async {
|
||||
if (_usuarioId == null) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final direccion = _nuevaDireccionController.text.trim();
|
||||
if (_nuevaColoniaSeleccionada == null) {
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Selecciona una colonia.')));
|
||||
return;
|
||||
}
|
||||
if (direccion.isEmpty) {
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Ingresa la dirección.')));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _apiService.agregarDireccion(_usuarioId!, _nuevaColoniaSeleccionada!, direccion);
|
||||
_nuevaDireccionController.clear();
|
||||
_nuevaColoniaSeleccionada = null;
|
||||
await _cargarUsuario();
|
||||
await _cargarETA();
|
||||
if (mounted) messenger.showSnackBar(const SnackBar(content: Text('✅ Dirección agregada.')));
|
||||
} catch (e) {
|
||||
if (mounted) messenger.showSnackBar(const SnackBar(content: Text('No se pudo agregar la dirección.')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mostrarAgregarDireccionDialog() async {
|
||||
if (_cargandoColonias) await _cargarColonias();
|
||||
if (!mounted) return;
|
||||
_nuevaDireccionController.clear();
|
||||
_nuevaColoniaSeleccionada = null;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Agregar dirección'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_errorColonias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_errorColonias!, style: TextStyle(color: Colors.orange.shade700, fontSize: 12)),
|
||||
),
|
||||
TextField(
|
||||
controller: _nuevaDireccionController,
|
||||
decoration: const InputDecoration(labelText: 'Dirección', hintText: 'Calle, número'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _nuevaColoniaSeleccionada,
|
||||
hint: const Text('Selecciona tu colonia'),
|
||||
items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
|
||||
onChanged: (v) => setState(() => _nuevaColoniaSeleccionada = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancelar')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final nav = Navigator.of(context);
|
||||
await _agregarDireccion();
|
||||
if (mounted) nav.pop();
|
||||
},
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── HELPERS ─────────────────────────────────────────────────────
|
||||
|
||||
Color _colorSegunETA(int eta) {
|
||||
if (eta <= 5) return const Color(0xFFB71C1C);
|
||||
if (eta <= 15) return const Color(0xFFE65100);
|
||||
if (eta <= 30) return const Color(0xFF1B5E20);
|
||||
return const Color(0xFF1A237E);
|
||||
}
|
||||
|
||||
String _emojiSegunETA(int eta) {
|
||||
if (eta <= 5) return '🔴';
|
||||
if (eta <= 15) return '🟡';
|
||||
if (eta <= 30) return '🟢';
|
||||
return '🔵';
|
||||
}
|
||||
|
||||
String _textoEstado(int eta) {
|
||||
if (eta <= 5) return '¡Saca tu basura AHORA!';
|
||||
if (eta <= 15) return 'Prepárate, viene pronto';
|
||||
if (eta <= 30) return 'En camino a tu zona';
|
||||
return 'Aún falta un rato';
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BUILD
|
||||
// ================================================================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseColor = _etaInfo != null
|
||||
? _colorSegunETA(_etaInfo!.etaMinutos)
|
||||
: const Color(0xFF1B5E20);
|
||||
|
||||
return Scaffold(
|
||||
body: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [baseColor, baseColor.withValues(alpha: 0.85), const Color(0xFF0D1B0F)],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _cargando
|
||||
? _buildCargando()
|
||||
: _error != null
|
||||
? _buildError()
|
||||
: FadeTransition(opacity: _fadeAnimation, child: _buildContenido()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCargando() => const Center(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
SizedBox(height: 16),
|
||||
Text('Consultando estado del camión...', style: TextStyle(color: Colors.white60, fontSize: 15)),
|
||||
]),
|
||||
);
|
||||
|
||||
Widget _buildError() => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 72, color: Colors.white38),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 17)),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _cargarETA,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Reintentar'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.red.shade700),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildContenido() {
|
||||
if (_etaInfo == null) return const SizedBox.shrink();
|
||||
final eta = _etaInfo!;
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── HEADER ──────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_nombreUsuario.isNotEmpty)
|
||||
Text(
|
||||
'Hola, ${_nombreUsuario.split(' ').first} 👋',
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 13),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on_rounded, color: Colors.white70, size: 15),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
eta.colonia,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 17),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pushNamed(context, '/mapa'),
|
||||
icon: const Icon(Icons.map_rounded, color: Colors.white60),
|
||||
tooltip: 'Ver mapa',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pushNamed(context, '/routes'),
|
||||
icon: const Icon(Icons.local_shipping_rounded, color: Colors.white60),
|
||||
tooltip: 'Ver rutas',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white60),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
onSelected: (v) {
|
||||
if (v == 'password') _mostrarCambioPassword();
|
||||
if (v == 'logout') _confirmarCerrarSesion();
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: 'password',
|
||||
child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 10), Text('Cambiar contraseña')]),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Row(children: [Icon(Icons.logout, size: 18, color: Colors.red), SizedBox(width: 10), Text('Cerrar sesión', style: TextStyle(color: Colors.red))]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── CHIP DE ESTADO GPS ───────────────────────────────────
|
||||
if (!eta.gpsOk)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
|
||||
child: _GlassChip(
|
||||
icon: Icons.gps_off,
|
||||
label: 'GPS del camión desconectado',
|
||||
color: Colors.red.shade300,
|
||||
),
|
||||
),
|
||||
|
||||
// ── CÍRCULO ETA PRINCIPAL ────────────────────────────────
|
||||
const SizedBox(height: 24),
|
||||
ScaleTransition(
|
||||
scale: _pulseAnimation,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Anillo exterior difuso
|
||||
Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
// Anillo con blur
|
||||
ClipOval(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
||||
child: Container(
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.4), width: 2),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_emojiSegunETA(eta.etaMinutos), style: const TextStyle(fontSize: 36)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${eta.etaMinutos}',
|
||||
style: const TextStyle(fontSize: 58, fontWeight: FontWeight.w900, color: Colors.white, height: 1),
|
||||
),
|
||||
const Text('minutos', style: TextStyle(fontSize: 14, color: Colors.white70, letterSpacing: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_textoEstado(eta.etaMinutos),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
eta.etaTexto,
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 13),
|
||||
),
|
||||
|
||||
// ── CARD MENSAJE PREVENTIVO ──────────────────────────────
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _GlassCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
eta.mensajePreventivo,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── INFO DE RUTA ─────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _GlassCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.local_shipping_rounded, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(eta.rutaNombre, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: eta.rutaStatus == 'EN_RUTA'
|
||||
? Colors.green.withValues(alpha: 0.3)
|
||||
: Colors.blue.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
eta.rutaStatus,
|
||||
style: TextStyle(
|
||||
color: eta.rutaStatus == 'EN_RUTA' ? Colors.greenAccent : Colors.lightBlueAccent,
|
||||
fontSize: 11, fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── ACCESOS RÁPIDOS ──────────────────────────────────────
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text('Accesos rápidos', style: TextStyle(color: Colors.white60, fontSize: 12, letterSpacing: 0.8)),
|
||||
),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.55,
|
||||
children: [
|
||||
_AccesoRapido(
|
||||
icon: Icons.local_shipping_rounded,
|
||||
label: 'Rutas',
|
||||
sublabel: 'Estado del camión',
|
||||
color: const Color(0xFF1565C0),
|
||||
onTap: () => Navigator.pushNamed(context, '/routes'),
|
||||
),
|
||||
_AccesoRapido(
|
||||
icon: Icons.analytics_rounded,
|
||||
label: 'Análisis',
|
||||
sublabel: 'Reportes y predicción',
|
||||
color: const Color(0xFF6A1B9A),
|
||||
onTap: () => Navigator.pushNamed(context, '/analytics'),
|
||||
),
|
||||
_AccesoRapido(
|
||||
icon: Icons.report_problem_rounded,
|
||||
label: 'Reportar',
|
||||
sublabel: 'Enviar un reporte',
|
||||
color: const Color(0xFFE65100),
|
||||
onTap: () => Navigator.pushNamed(context, '/reporte'),
|
||||
),
|
||||
_AccesoRapido(
|
||||
icon: Icons.eco_rounded,
|
||||
label: 'Información',
|
||||
sublabel: 'Guía de residuos',
|
||||
color: const Color(0xFF2E7D32),
|
||||
onTap: () => Navigator.pushNamed(context, '/info'),
|
||||
),
|
||||
_AccesoRapido(
|
||||
icon: Icons.map_rounded,
|
||||
label: 'Mapa',
|
||||
sublabel: 'Ver rutas en mapa',
|
||||
color: const Color(0xFF00838F),
|
||||
onTap: () => Navigator.pushNamed(context, '/mapa'),
|
||||
),
|
||||
_AccesoRapido(
|
||||
icon: Icons.add_location_alt_rounded,
|
||||
label: 'Dirección',
|
||||
sublabel: 'Agregar domicilio',
|
||||
color: const Color(0xFFC62828),
|
||||
onTap: _mostrarAgregarDireccionDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── DIRECCIONES ──────────────────────────────────────────
|
||||
if (_direcciones.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _GlassCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.home_rounded, color: Colors.white70, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Mis direcciones', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _mostrarAgregarDireccionDialog,
|
||||
child: const Icon(Icons.add_circle_outline, color: Colors.white54, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._direcciones.map((d) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6, height: 6,
|
||||
decoration: const BoxDecoration(color: Colors.white54, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(d.colonia, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
Text(d.direccion, style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// ── FOOTER ───────────────────────────────────────────────
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 28),
|
||||
child: Column(children: [
|
||||
const Text('🔄 Actualización automática cada minuto',
|
||||
style: TextStyle(color: Colors.white30, fontSize: 11)),
|
||||
const SizedBox(height: 10),
|
||||
TextButton.icon(
|
||||
onPressed: _cargarETA,
|
||||
icon: const Icon(Icons.refresh, color: Colors.white38, size: 16),
|
||||
label: const Text('Actualizar ahora', style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// WIDGETS AUXILIARES
|
||||
// ================================================================
|
||||
|
||||
/// Tarjeta con efecto glassmorphism
|
||||
class _GlassCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const _GlassCard({required this.child, this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip de estado pequeño
|
||||
class _GlassChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _GlassChip({required this.icon, required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tarjeta de acceso rápido en el grid
|
||||
class _AccesoRapido extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String sublabel;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AccesoRapido({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.sublabel,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(sublabel, style: const TextStyle(color: Colors.white54, fontSize: 10), overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
808
HackOnLinces_app/aplicacion_hack/lib/screens/info_screen.dart
Normal file
808
HackOnLinces_app/aplicacion_hack/lib/screens/info_screen.dart
Normal file
@@ -0,0 +1,808 @@
|
||||
// ================================================================
|
||||
// lib/screens/info_screen.dart — EcoTrack
|
||||
// Pantalla de Informacion + Tutorial interactivo
|
||||
// ================================================================
|
||||
//
|
||||
// ASSETS USADOS:
|
||||
// assets/images/recycle.jpg → Separacion / reciclaje
|
||||
// assets/images/bottle.png → Plasticos
|
||||
// assets/images/planta.png → Composta / Medio ambiente
|
||||
// assets/images/megafono.png → Residuos peligrosos / Horarios
|
||||
//
|
||||
// SECCIONES:
|
||||
// 1. Tutorial interactivo (swipe de pasos)
|
||||
// 2. Articulos de informacion por categoria
|
||||
// 3. Detalle de articulo con imagen
|
||||
// ================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ================================================================
|
||||
// MODELOS
|
||||
// ================================================================
|
||||
|
||||
class _Subseccion {
|
||||
final String subtitulo;
|
||||
final String texto;
|
||||
const _Subseccion(this.subtitulo, this.texto);
|
||||
}
|
||||
|
||||
class _Articulo {
|
||||
final String id;
|
||||
final String categoria;
|
||||
final String titulo;
|
||||
final String resumen;
|
||||
final String imagenAsset;
|
||||
final Color color;
|
||||
final List<_Subseccion> contenido;
|
||||
final String consejoRapido;
|
||||
|
||||
const _Articulo({
|
||||
required this.id,
|
||||
required this.categoria,
|
||||
required this.titulo,
|
||||
required this.resumen,
|
||||
required this.imagenAsset,
|
||||
required this.color,
|
||||
required this.contenido,
|
||||
required this.consejoRapido,
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DATOS
|
||||
// ================================================================
|
||||
|
||||
const _articulos = [
|
||||
_Articulo(
|
||||
id: 'separacion',
|
||||
categoria: 'Separacion',
|
||||
titulo: 'Como separar correctamente tu basura',
|
||||
resumen: 'La separacion correcta es el primer paso para reciclar y reducir el impacto ambiental.',
|
||||
imagenAsset: 'assets/images/recycle.jpg',
|
||||
color: Color(0xFF2E7D32),
|
||||
consejoRapido: 'Regla facil: si vino de la naturaleza y se pudre, es organico. Si es artificial y esta limpio, es reciclable.',
|
||||
contenido: [
|
||||
_Subseccion('Residuos Organicos', 'Restos de comida, cascaras de frutas y verduras, posos de cafe, bolsas de te, restos de jardin. Van en bolsa oscura o cafe. Se convierten en composta.'),
|
||||
_Subseccion('Inorganicos Reciclables', 'Plasticos (botellas PET, envases), papel y carton limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente. Deben estar limpios y secos.'),
|
||||
_Subseccion('No Reciclables', 'Papel higienico usado, panales, colillas de cigarro, envolturas metalizadas. Van en bolsa negra. No tienen valor de reciclaje.'),
|
||||
_Subseccion('Residuos Especiales', 'Pilas, medicamentos caducados, electronicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas a puntos de acopio en supermercados.'),
|
||||
],
|
||||
),
|
||||
_Articulo(
|
||||
id: 'horarios',
|
||||
categoria: 'Horarios',
|
||||
titulo: 'Cuando sacar tu basura',
|
||||
resumen: 'Sacar la basura en el momento correcto evita plagas, malos olores y que el camion se la pierda.',
|
||||
imagenAsset: 'assets/images/megafono.png',
|
||||
color: Color(0xFF1565C0),
|
||||
consejoRapido: 'Espera la alerta de EcoTrack antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.',
|
||||
contenido: [
|
||||
_Subseccion('El momento ideal', 'Saca tu basura cuando recibas la alerta de "Camion Cercano" en EcoTrack. Eso significa que el camion esta a menos de 15 minutos de tu domicilio.'),
|
||||
_Subseccion('Por que no de noche', 'Las bolsas en la acera de noche atraen perros y fauna nocturna que las rompen y dispersan los residuos. Ademas el plastico se deteriora con la humedad nocturna.'),
|
||||
_Subseccion('Si me lo pierdo', 'Si el camion ya paso, guarda tu basura hasta el siguiente dia. Nunca dejes bolsas en la via publica fuera del horario de recoleccion.'),
|
||||
_Subseccion('Dias festivos', 'En dias festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de EcoTrack para recibir alertas de retraso o cambio de horario.'),
|
||||
],
|
||||
),
|
||||
_Articulo(
|
||||
id: 'plasticos',
|
||||
categoria: 'Reciclaje',
|
||||
titulo: 'Guia de plasticos: cuales si y cuales no',
|
||||
resumen: 'No todos los plasticos son iguales. Aprende a leer el numero en el triangulo de reciclaje.',
|
||||
imagenAsset: 'assets/images/bottle.png',
|
||||
color: Color(0xFF00838F),
|
||||
consejoRapido: 'Busca el numero dentro del triangulo en el fondo del envase. Los numeros 1 y 2 siempre van al reciclaje.',
|
||||
contenido: [
|
||||
_Subseccion('Plastico #1 PET', 'Botellas de agua y refrescos. El mas reciclado. Aplastalo para ahorrar espacio. Quita la tapa porque es un material diferente.'),
|
||||
_Subseccion('Plastico #2 HDPE', 'Garrafones, botellas de leche, shampoo. Tambien muy reciclable. Enjuagalo antes de separarlo.'),
|
||||
_Subseccion('Plastico #5 PP', 'Tapas de botellas, envases de yogur. Si se recicla pero menos centros lo aceptan.'),
|
||||
_Subseccion('Plasticos 3, 6 y 7', 'PVC, unicel, policarbonato. Dificiles o imposibles de reciclar. Van a basura no reciclable.'),
|
||||
_Subseccion('Bolsas de plastico', 'No van en el reciclaje de casa porque tapan las maquinas clasificadoras. Lleva tus bolsas a centros de acopio en supermercados.'),
|
||||
],
|
||||
),
|
||||
_Articulo(
|
||||
id: 'composta',
|
||||
categoria: 'Compostaje',
|
||||
titulo: 'Haz composta en casa',
|
||||
resumen: 'Convierte tus residuos organicos en abono natural. Es mas facil de lo que crees.',
|
||||
imagenAsset: 'assets/images/planta.png',
|
||||
color: Color(0xFF558B2F),
|
||||
consejoRapido: 'La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega mas material seco y volteala.',
|
||||
contenido: [
|
||||
_Subseccion('Que necesitas', 'Un contenedor con tapa, residuos organicos, tierra o tierra de hojarasca, y un poco de paciencia.'),
|
||||
_Subseccion('Que puedes compostar', 'Cascaras de frutas y verduras, restos de comida cocida sin carne, posos de cafe y filtros de papel, cascaras de huevo, hojas secas.'),
|
||||
_Subseccion('Que NO debes compostar', 'Carnes, pescados, lacteos, aceites ya que atraen plagas, excrementos de mascotas, plasticos ni metales.'),
|
||||
_Subseccion('El proceso', 'Alterna capas de residuos organicos humedos con capas de material seco. Voltea la mezcla cada semana. En 2 a 3 meses tendras composta lista para tus plantas.'),
|
||||
],
|
||||
),
|
||||
_Articulo(
|
||||
id: 'peligrosos',
|
||||
categoria: 'Residuos Especiales',
|
||||
titulo: 'Residuos peligrosos: como deshacerte de ellos',
|
||||
resumen: 'Pilas, medicamentos y electronicos requieren un manejo especial para no contaminar el suelo y el agua.',
|
||||
imagenAsset: 'assets/images/megafono.png',
|
||||
color: Color(0xFFE65100),
|
||||
consejoRapido: 'Guarda una caja en casa exclusiva para residuos peligrosos. Cuando este llena, busca el punto de acopio mas cercano.',
|
||||
contenido: [
|
||||
_Subseccion('Pilas y baterias', 'Una sola pila AA puede contaminar 600,000 litros de agua. Guardalas en una bolsa y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO.'),
|
||||
_Subseccion('Medicamentos caducados', 'No los tires al drenaje ni a la basura regular. Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos.'),
|
||||
_Subseccion('Electronicos RAEE', 'Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electronicos o espera las jornadas municipales.'),
|
||||
_Subseccion('Aceite de cocina', 'Un litro de aceite contamina hasta 1,000 litros de agua potable. Viertelo en una botella PET con tapa y lleva a centros de acopio.'),
|
||||
],
|
||||
),
|
||||
_Articulo(
|
||||
id: 'impacto',
|
||||
categoria: 'Medio Ambiente',
|
||||
titulo: 'El impacto real de reciclar',
|
||||
resumen: 'Numeros concretos para entender por que vale la pena separar tu basura cada dia.',
|
||||
imagenAsset: 'assets/images/planta.png',
|
||||
color: Color(0xFF4527A0),
|
||||
consejoRapido: 'Cada lata de aluminio que reciclas ahorra energia equivalente a medio litro de gasolina. Si importa.',
|
||||
contenido: [
|
||||
_Subseccion('Papel y carton', 'Reciclar 1 tonelada de papel salva 17 arboles y ahorra 26,000 litros de agua. Una familia promedio genera 500 kg de papel al ano.'),
|
||||
_Subseccion('Aluminio', 'Reciclar una lata de aluminio ahorra la energia suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces.'),
|
||||
_Subseccion('Vidrio', 'El vidrio tarda mas de 4,000 anos en degradarse. Reciclarlo reduce en 20% las emisiones de CO2 de su produccion.'),
|
||||
_Subseccion('Residuos en Mexico', 'Mexico genera 120,000 toneladas de basura al dia. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podria triplicarse.'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
// ── Pasos del tutorial ─────────────────────────────────────────
|
||||
|
||||
class _PasoTutorial {
|
||||
final String titulo;
|
||||
final String descripcion;
|
||||
final String imagenAsset;
|
||||
final Color color;
|
||||
final IconData icono;
|
||||
|
||||
const _PasoTutorial({
|
||||
required this.titulo,
|
||||
required this.descripcion,
|
||||
required this.imagenAsset,
|
||||
required this.color,
|
||||
required this.icono,
|
||||
});
|
||||
}
|
||||
|
||||
const _pasosTutorial = [
|
||||
_PasoTutorial(
|
||||
titulo: 'Bienvenido a EcoTrack',
|
||||
descripcion: 'EcoTrack te notifica cuando el camion recolector esta cerca de tu domicilio para que saques tu basura en el momento exacto.',
|
||||
imagenAsset: 'assets/images/recycle.jpg',
|
||||
color: Color(0xFF2E7D32),
|
||||
icono: Icons.recycling_rounded,
|
||||
),
|
||||
_PasoTutorial(
|
||||
titulo: 'Separa tus residuos',
|
||||
descripcion: 'Separa tu basura en organicos, reciclables y no reciclables. Esto facilita el trabajo del camion y reduce el impacto ambiental.',
|
||||
imagenAsset: 'assets/images/bottle.png',
|
||||
color: Color(0xFF00838F),
|
||||
icono: Icons.category_rounded,
|
||||
),
|
||||
_PasoTutorial(
|
||||
titulo: 'Espera la alerta',
|
||||
descripcion: 'Activa las notificaciones de EcoTrack. Te avisaremos cuando el camion este a menos de 15 minutos de tu casa.',
|
||||
imagenAsset: 'assets/images/megafono.png',
|
||||
color: Color(0xFF1565C0),
|
||||
icono: Icons.notifications_active_rounded,
|
||||
),
|
||||
_PasoTutorial(
|
||||
titulo: 'Saca la basura a tiempo',
|
||||
descripcion: 'Al recibir la alerta, saca tus bolsas a la acera. Evita sacarlas muy antes para no atraer fauna y mantener limpia la calle.',
|
||||
imagenAsset: 'assets/images/recycle.jpg',
|
||||
color: Color(0xFFE65100),
|
||||
icono: Icons.access_time_filled_rounded,
|
||||
),
|
||||
_PasoTutorial(
|
||||
titulo: 'Envia reportes',
|
||||
descripcion: 'Si el camion no paso o detectas alguna irregularidad, usa la seccion de Reportes para informar al municipio. Tu participacion mejora el servicio.',
|
||||
imagenAsset: 'assets/images/megafono.png',
|
||||
color: Color(0xFF6A1B9A),
|
||||
icono: Icons.report_problem_rounded,
|
||||
),
|
||||
];
|
||||
|
||||
// ================================================================
|
||||
// PANTALLA PRINCIPAL
|
||||
// ================================================================
|
||||
class InfoScreen extends StatefulWidget {
|
||||
const InfoScreen({super.key});
|
||||
|
||||
@override
|
||||
State<InfoScreen> createState() => _InfoScreenState();
|
||||
}
|
||||
|
||||
class _InfoScreenState extends State<InfoScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
String? _categoriaFiltro;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<String> get _categorias =>
|
||||
_articulos.map((a) => a.categoria).toSet().toList();
|
||||
|
||||
List<_Articulo> get _articulosFiltrados =>
|
||||
_categoriaFiltro == null
|
||||
? _articulos
|
||||
: _articulos.where((a) => a.categoria == _categoriaFiltro).toList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('EcoTrack — Aprende'),
|
||||
backgroundColor: const Color(0xFF2E7D32),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white60,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.play_circle_outline_rounded), text: 'Tutorial'),
|
||||
Tab(icon: Icon(Icons.menu_book_rounded), text: 'Guias'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_TutorialView(),
|
||||
_GuiasView(
|
||||
categorias: _categorias,
|
||||
categoriaFiltro: _categoriaFiltro,
|
||||
articulos: _articulosFiltrados,
|
||||
onFiltro: (c) => setState(() => _categoriaFiltro = c),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// TAB 1: TUTORIAL INTERACTIVO
|
||||
// ================================================================
|
||||
class _TutorialView extends StatefulWidget {
|
||||
@override
|
||||
State<_TutorialView> createState() => _TutorialViewState();
|
||||
}
|
||||
|
||||
class _TutorialViewState extends State<_TutorialView> {
|
||||
final PageController _pageCtrl = PageController();
|
||||
int _paginaActual = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _siguiente() {
|
||||
if (_paginaActual < _pasosTutorial.length - 1) {
|
||||
_pageCtrl.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
}
|
||||
}
|
||||
|
||||
void _anterior() {
|
||||
if (_paginaActual > 0) {
|
||||
_pageCtrl.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Indicador de progreso
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: Row(
|
||||
children: List.generate(_pasosTutorial.length, (i) {
|
||||
final activo = i == _paginaActual;
|
||||
final completado = i < _paginaActual;
|
||||
return Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: completado
|
||||
? const Color(0xFF2E7D32)
|
||||
: activo
|
||||
? _pasosTutorial[_paginaActual].color
|
||||
: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Paso ${_paginaActual + 1} de ${_pasosTutorial.length}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_paginaActual == _pasosTutorial.length - 1 ? 'Completado' : '',
|
||||
style: const TextStyle(color: Color(0xFF2E7D32), fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Páginas del tutorial
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageCtrl,
|
||||
itemCount: _pasosTutorial.length,
|
||||
onPageChanged: (i) => setState(() => _paginaActual = i),
|
||||
itemBuilder: (context, i) => _PaginaTutorial(paso: _pasosTutorial[i]),
|
||||
),
|
||||
),
|
||||
|
||||
// Controles de navegación
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_paginaActual > 0)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _anterior,
|
||||
icon: const Icon(Icons.arrow_back_rounded, size: 18),
|
||||
label: const Text('Anterior'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_paginaActual > 0) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: _paginaActual == 0 ? 1 : 1,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _paginaActual == _pasosTutorial.length - 1 ? null : _siguiente,
|
||||
icon: Icon(
|
||||
_paginaActual == _pasosTutorial.length - 1
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.arrow_forward_rounded,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
_paginaActual == _pasosTutorial.length - 1 ? 'Listo' : 'Siguiente',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _pasosTutorial[_paginaActual].color,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaginaTutorial extends StatelessWidget {
|
||||
final _PasoTutorial paso;
|
||||
const _PaginaTutorial({required this.paso});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Imagen con overlay de icono
|
||||
Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
paso.imagenAsset,
|
||||
height: 220,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: paso.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(paso.icono, size: 80, color: paso.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Badge del icono
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
width: 52, height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: paso.color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: paso.color.withValues(alpha: 0.4), blurRadius: 12)],
|
||||
),
|
||||
child: Icon(paso.icono, color: Colors.white, size: 26),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Titulo
|
||||
Text(
|
||||
paso.titulo,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: paso.color, height: 1.2),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Descripcion
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
|
||||
),
|
||||
child: Text(
|
||||
paso.descripcion,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF444444), height: 1.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// TAB 2: GUIAS DE INFORMACION
|
||||
// ================================================================
|
||||
class _GuiasView extends StatelessWidget {
|
||||
final List<String> categorias;
|
||||
final String? categoriaFiltro;
|
||||
final List<_Articulo> articulos;
|
||||
final void Function(String?) onFiltro;
|
||||
|
||||
const _GuiasView({
|
||||
required this.categorias,
|
||||
required this.categoriaFiltro,
|
||||
required this.articulos,
|
||||
required this.onFiltro,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Filtros de categoria
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
_FiltroChip(
|
||||
label: 'Todas',
|
||||
seleccionado: categoriaFiltro == null,
|
||||
color: const Color(0xFF2E7D32),
|
||||
onTap: () => onFiltro(null),
|
||||
),
|
||||
...categorias.map((cat) {
|
||||
final a = _articulos.firstWhere((x) => x.categoria == cat, orElse: () => _articulos.first);
|
||||
return _FiltroChip(
|
||||
label: cat,
|
||||
seleccionado: categoriaFiltro == cat,
|
||||
color: a.color,
|
||||
onTap: () => onFiltro(cat),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Lista de articulos
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
itemCount: articulos.length,
|
||||
itemBuilder: (context, i) => _TarjetaArticulo(
|
||||
articulo: articulos[i],
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => _DetalleArticuloScreen(articulo: articulos[i])),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// WIDGET: Chip de filtro
|
||||
// ================================================================
|
||||
class _FiltroChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool seleccionado;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FiltroChip({required this.label, required this.seleccionado, required this.color, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: seleccionado ? color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: seleccionado ? Colors.white : color, fontWeight: FontWeight.w600, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// WIDGET: Tarjeta de articulo
|
||||
// ================================================================
|
||||
class _TarjetaArticulo extends StatelessWidget {
|
||||
final _Articulo articulo;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TarjetaArticulo({required this.articulo, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Imagen lateral
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
|
||||
child: Image.asset(
|
||||
articulo.imagenAsset,
|
||||
width: 90,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 90, height: 100,
|
||||
color: articulo.color.withValues(alpha: 0.1),
|
||||
child: Icon(Icons.eco_rounded, color: articulo.color, size: 36),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenido
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: articulo.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(articulo.categoria,
|
||||
style: TextStyle(color: articulo.color, fontSize: 10, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(articulo.titulo,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A)),
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text(articulo.resumen,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.3),
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Icon(Icons.chevron_right_rounded, color: Colors.grey.shade400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PANTALLA DE DETALLE
|
||||
// ================================================================
|
||||
class _DetalleArticuloScreen extends StatelessWidget {
|
||||
final _Articulo articulo;
|
||||
const _DetalleArticuloScreen({required this.articulo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7F5),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 220,
|
||||
pinned: true,
|
||||
backgroundColor: articulo.color,
|
||||
foregroundColor: Colors.white,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: Text(
|
||||
articulo.titulo,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
),
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(
|
||||
articulo.imagenAsset,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: articulo.color),
|
||||
),
|
||||
// Gradiente oscuro para legibilidad del titulo
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, articulo.color.withValues(alpha: 0.85)],
|
||||
stops: const [0.4, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Badge categoria
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: articulo.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(articulo.categoria,
|
||||
style: TextStyle(color: articulo.color, fontWeight: FontWeight.w700, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Resumen
|
||||
Text(articulo.resumen,
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF333333), height: 1.5)),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Secciones
|
||||
...articulo.contenido.map((s) => _SeccionCard(seccion: s, color: articulo.color)),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Consejo destacado
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: articulo.color,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.lightbulb_outline_rounded, color: Colors.white.withValues(alpha: 0.9), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Consejo rapido',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(articulo.consejoRapido,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeccionCard extends StatelessWidget {
|
||||
final _Subseccion seccion;
|
||||
final Color color;
|
||||
const _SeccionCard({required this.seccion, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border(left: BorderSide(color: color, width: 4)),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(seccion.subtitulo,
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: color)),
|
||||
const SizedBox(height: 6),
|
||||
Text(seccion.texto,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF444444), height: 1.5)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
402
HackOnLinces_app/aplicacion_hack/lib/screens/login_screen.dart
Normal file
402
HackOnLinces_app/aplicacion_hack/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
// ================================================================
|
||||
// lib/screens/login_screen.dart — EcoTrack
|
||||
// ================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _dirCtrl = TextEditingController();
|
||||
|
||||
String? _coloniaSeleccionada;
|
||||
List<String> _colonias = [];
|
||||
bool _esRegistro = false;
|
||||
bool _cargandoColonias = true;
|
||||
String? _errorColonias;
|
||||
bool _logueando = false;
|
||||
bool _mostrarPassword = false;
|
||||
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarColonias();
|
||||
_verificarSesion();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
_nameCtrl.dispose();
|
||||
_dirCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _verificarSesion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt('usuario_id');
|
||||
if (id != null && mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home', arguments: id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cargarColonias() async {
|
||||
try {
|
||||
final colonias = await _apiService.obtenerColonias();
|
||||
if (mounted) setState(() { _colonias = colonias; _cargandoColonias = false; });
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_colonias = ['Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes'];
|
||||
_cargandoColonias = false;
|
||||
_errorColonias = 'Sin conexión al backend. Usando lista local.';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _iniciarSesion() async {
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
if (email.isEmpty) { _error('Ingresa tu correo.'); return; }
|
||||
if (password.isEmpty) { _error('Ingresa tu contraseña.'); return; }
|
||||
|
||||
setState(() => _logueando = true);
|
||||
try {
|
||||
final r = await _apiService.loginConCorreo(email, password);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('usuario_id', r['usuario_id']);
|
||||
await prefs.setString('nombre', r['nombre'] ?? '');
|
||||
await prefs.setString('email', email);
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: r['usuario_id']);
|
||||
} catch (e) {
|
||||
_error(e.toString().replaceFirst('Exception: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _logueando = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registrarse() async {
|
||||
final nombre = _nameCtrl.text.trim();
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
final dir = _dirCtrl.text.trim();
|
||||
|
||||
if (nombre.isEmpty) { _error('Ingresa tu nombre.'); return; }
|
||||
if (email.isEmpty) { _error('Ingresa tu correo.'); return; }
|
||||
if (password.length < 6) { _error('Contraseña de al menos 6 caracteres.'); return; }
|
||||
if (_coloniaSeleccionada == null) { _error('Selecciona tu colonia.'); return; }
|
||||
if (dir.isEmpty) { _error('Ingresa tu dirección.'); return; }
|
||||
|
||||
setState(() => _logueando = true);
|
||||
try {
|
||||
final id = await _apiService.registrarUsuario(nombre, email, password, dir, _coloniaSeleccionada!);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('usuario_id', id);
|
||||
await prefs.setString('nombre', nombre);
|
||||
await prefs.setString('email', email);
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: id);
|
||||
} catch (e) {
|
||||
_error(e.toString().replaceFirst('Exception: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _logueando = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _error(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BUILD
|
||||
// ================================================================
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF6FAF6),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── LOGO ─────────────────────────────────────────────
|
||||
Center(
|
||||
child: Container(
|
||||
width: 84, height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [BoxShadow(color: color.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 8))],
|
||||
),
|
||||
child: const Icon(Icons.recycling_rounded, size: 48, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('EcoTrack',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w900, color: color, letterSpacing: -0.5),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Recolección inteligente de residuos',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ── TABS LOGIN / REGISTRO ─────────────────────────────
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Tab(label: 'Iniciar sesión', seleccionado: !_esRegistro, color: color,
|
||||
onTap: () => setState(() { _esRegistro = false; _passwordCtrl.clear(); })),
|
||||
_Tab(label: 'Registrarse', seleccionado: _esRegistro, color: color,
|
||||
onTap: () => setState(() { _esRegistro = true; _passwordCtrl.clear(); })),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── CAMPOS COMUNES ────────────────────────────────────
|
||||
if (_esRegistro) ...[
|
||||
_Campo(ctrl: _nameCtrl, label: 'Nombre completo', icon: Icons.person_outline),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
_Campo(ctrl: _emailCtrl, label: 'Correo electrónico', icon: Icons.email_outlined, tipo: TextInputType.emailAddress),
|
||||
const SizedBox(height: 14),
|
||||
_Campo(
|
||||
ctrl: _passwordCtrl,
|
||||
label: 'Contraseña',
|
||||
icon: Icons.lock_outline,
|
||||
obscure: !_mostrarPassword,
|
||||
sufijo: IconButton(
|
||||
icon: Icon(_mostrarPassword ? Icons.visibility_off : Icons.visibility, size: 20),
|
||||
onPressed: () => setState(() => _mostrarPassword = !_mostrarPassword),
|
||||
),
|
||||
),
|
||||
|
||||
// ── CAMPOS EXTRA REGISTRO ─────────────────────────────
|
||||
if (_esRegistro) ...[
|
||||
const SizedBox(height: 14),
|
||||
_Campo(ctrl: _dirCtrl, label: 'Dirección', icon: Icons.home_outlined),
|
||||
const SizedBox(height: 14),
|
||||
if (_cargandoColonias)
|
||||
const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
|
||||
else ...[
|
||||
if (_errorColonias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(_errorColonias!, style: TextStyle(fontSize: 11, color: Colors.orange.shade700)),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _coloniaSeleccionada,
|
||||
hint: const Text('Selecciona tu colonia'),
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.location_city_outlined),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
|
||||
onChanged: (v) => setState(() => _coloniaSeleccionada = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
_IndicadorFortaleza(password: _passwordCtrl.text),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── BOTÓN PRINCIPAL ───────────────────────────────────
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _logueando ? null : (_esRegistro ? _registrarse : _iniciarSesion),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _logueando
|
||||
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: Text(_esRegistro ? 'Crear cuenta' : 'Entrar',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
_esRegistro
|
||||
? 'Tu contraseña se almacena de forma segura con bcrypt. Minimo 6 caracteres.'
|
||||
: 'Tus datos son privados y solo se usan para notificarte cuando el camion se acerca.',
|
||||
style: TextStyle(fontSize: 11, color: color),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// WIDGETS AUXILIARES
|
||||
// ================================================================
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
final String label;
|
||||
final bool seleccionado;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Tab({required this.label, required this.seleccionado, required this.color, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.all(4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: seleccionado ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
boxShadow: seleccionado ? [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 6)] : [],
|
||||
),
|
||||
child: Text(label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal,
|
||||
color: seleccionado ? color : Colors.grey.shade500,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Campo extends StatelessWidget {
|
||||
final TextEditingController ctrl;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool obscure;
|
||||
final TextInputType tipo;
|
||||
final Widget? sufijo;
|
||||
|
||||
const _Campo({
|
||||
required this.ctrl,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.obscure = false,
|
||||
this.tipo = TextInputType.text,
|
||||
this.sufijo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: ctrl,
|
||||
obscureText: obscure,
|
||||
keyboardType: tipo,
|
||||
textCapitalization: tipo == TextInputType.text ? TextCapitalization.words : TextCapitalization.none,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon, size: 20),
|
||||
suffixIcon: sufijo,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 1.5)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicadorFortaleza extends StatelessWidget {
|
||||
final String password;
|
||||
const _IndicadorFortaleza({required this.password});
|
||||
|
||||
(int, String, Color) _evaluar() {
|
||||
if (password.isEmpty) return (0, '', Colors.grey);
|
||||
int pts = 0;
|
||||
if (password.length >= 8) pts++;
|
||||
if (password.contains(RegExp(r'[A-Z]'))) pts++;
|
||||
if (password.contains(RegExp(r'[0-9]'))) pts++;
|
||||
if (password.contains(RegExp(r'[!@#\$%^&*]'))) pts++;
|
||||
if (pts <= 1) return (1, 'Debil', Colors.red);
|
||||
if (pts == 2) return (2, 'Regular', Colors.orange);
|
||||
if (pts == 3) return (3, 'Buena', Colors.lightGreen);
|
||||
return (4, 'Excelente', Colors.green);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (password.isEmpty) return const SizedBox.shrink();
|
||||
final (nivel, etiqueta, color) = _evaluar();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: List.generate(4, (i) => Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: i < nivel ? color : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
))),
|
||||
const SizedBox(height: 4),
|
||||
Text('Contrasena: $etiqueta', style: TextStyle(fontSize: 11, color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// ================================================================
|
||||
// lib/screens/mapa_rutas_screen.dart (v2 — flutter_map)
|
||||
// Mapa real de Celaya con rutas estilo metro sobre OpenStreetMap
|
||||
// ================================================================
|
||||
//
|
||||
// DEPENDENCIAS (agregar en pubspec.yaml):
|
||||
// flutter_map: ^7.0.2
|
||||
// latlong2: ^0.9.1
|
||||
//
|
||||
// SIN API KEY — usa tiles de OpenStreetMap (gratis, libre)
|
||||
//
|
||||
// FILTRADO:
|
||||
// Solo las rutas del usuario se ven en color vivo.
|
||||
// Las demás se muestran al 20% de opacidad.
|
||||
// ================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// DATOS DE RUTAS
|
||||
// ----------------------------------------------------------------
|
||||
class _RutaInfo {
|
||||
final String routeId;
|
||||
final String nombre;
|
||||
final String colonia;
|
||||
final Color color;
|
||||
final List<LatLng> puntos;
|
||||
|
||||
const _RutaInfo({
|
||||
required this.routeId,
|
||||
required this.nombre,
|
||||
required this.colonia,
|
||||
required this.color,
|
||||
required this.puntos,
|
||||
});
|
||||
}
|
||||
|
||||
final _todasLasRutas = [
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-01',
|
||||
nombre: 'Zona Centro - Las Arboledas',
|
||||
colonia: 'Zona Centro',
|
||||
color: const Color(0xFF1565C0),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5185, -100.8450),
|
||||
LatLng(20.5215, -100.8142),
|
||||
LatLng(20.5212, -100.8175),
|
||||
LatLng(20.5210, -100.8210),
|
||||
LatLng(20.5235, -100.8212),
|
||||
LatLng(20.5260, -100.8215),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-03',
|
||||
nombre: 'Sector Poniente - San Juanico',
|
||||
colonia: 'San Juanico',
|
||||
color: const Color(0xFF2E7D32),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5250, -100.8510),
|
||||
LatLng(20.5290, -100.8320),
|
||||
LatLng(20.5315, -100.8355),
|
||||
LatLng(20.5340, -100.8390),
|
||||
LatLng(20.5362, -100.8425),
|
||||
LatLng(20.5330, -100.8430),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-04',
|
||||
nombre: 'Oriente - Los Olivos',
|
||||
colonia: 'Los Olivos',
|
||||
color: const Color(0xFFE65100),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5260, -100.8010),
|
||||
LatLng(20.5295, -100.7890),
|
||||
LatLng(20.5320, -100.7850),
|
||||
LatLng(20.5350, -100.7790),
|
||||
LatLng(20.5310, -100.7760),
|
||||
LatLng(20.5270, -100.7820),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-05',
|
||||
nombre: 'Sector Sur - Rancho Seco',
|
||||
colonia: 'Rancho Seco',
|
||||
color: const Color(0xFF6A1B9A),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5050, -100.8620),
|
||||
LatLng(20.5020, -100.8350),
|
||||
LatLng(20.4995, -100.8210),
|
||||
LatLng(20.4970, -100.8150),
|
||||
LatLng(20.5010, -100.8120),
|
||||
LatLng(20.5060, -100.8160),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-12',
|
||||
nombre: 'Nororiente - Las Insurgentes',
|
||||
colonia: 'Las Insurgentes',
|
||||
color: const Color(0xFFC62828),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5280, -100.8080),
|
||||
LatLng(20.5320, -100.7980),
|
||||
LatLng(20.5340, -100.7940),
|
||||
LatLng(20.5360, -100.7900),
|
||||
LatLng(20.5310, -100.7920),
|
||||
LatLng(20.5270, -100.8020),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
_RutaInfo(
|
||||
routeId: 'RUTA-13',
|
||||
nombre: 'Sector Norte - Trojes e Irrigación',
|
||||
colonia: 'Trojes',
|
||||
color: const Color(0xFF00838F),
|
||||
puntos: [
|
||||
LatLng(20.5111, -100.9037),
|
||||
LatLng(20.5360, -100.8190),
|
||||
LatLng(20.5420, -100.8080),
|
||||
LatLng(20.5440, -100.8040),
|
||||
LatLng(20.5460, -100.8000),
|
||||
LatLng(20.5410, -100.8020),
|
||||
LatLng(20.5370, -100.8120),
|
||||
LatLng(20.5111, -100.9037),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const _coloniaARuta = {
|
||||
'Zona Centro': 'RUTA-01',
|
||||
'Las Arboledas': 'RUTA-01',
|
||||
'San Juanico': 'RUTA-03',
|
||||
'Los Olivos': 'RUTA-04',
|
||||
'Rancho Seco': 'RUTA-05',
|
||||
'Las Insurgentes': 'RUTA-12',
|
||||
'Trojes': 'RUTA-13',
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// PANTALLA
|
||||
// ================================================================
|
||||
class MapaRutasScreen extends StatefulWidget {
|
||||
const MapaRutasScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MapaRutasScreen> createState() => _MapaRutasScreenState();
|
||||
}
|
||||
|
||||
class _MapaRutasScreenState extends State<MapaRutasScreen> {
|
||||
Set<String> _rutasDelUsuario = {};
|
||||
List<RouteInfo> _estadosBackend = [];
|
||||
String? _rutaSeleccionada;
|
||||
bool _cargando = true;
|
||||
bool _mostrarTodas = false;
|
||||
|
||||
final _mapController = MapController();
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
static const _centro = LatLng(20.5230, -100.8550);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarDatos();
|
||||
}
|
||||
|
||||
Future<void> _cargarDatos() async {
|
||||
setState(() => _cargando = true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final usuarioId = prefs.getInt('usuario_id');
|
||||
if (usuarioId == null) {
|
||||
if (mounted) setState(() => _cargando = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final rutas = await _apiService.obtenerRutas(usuarioId);
|
||||
final usuario = await _apiService.obtenerUsuario(usuarioId);
|
||||
final ids = <String>{};
|
||||
for (final d in usuario.direcciones) {
|
||||
final r = _coloniaARuta[d.colonia];
|
||||
if (r != null) ids.add(r);
|
||||
}
|
||||
for (final r in rutas) ids.add(r.routeId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_estadosBackend = rutas;
|
||||
_rutasDelUsuario = ids;
|
||||
_cargando = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _cargando = false);
|
||||
}
|
||||
}
|
||||
|
||||
RouteInfo? _estadoRuta(String routeId) {
|
||||
try {
|
||||
return _estadosBackend.firstWhere((r) => r.routeId == routeId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Mapa de rutas — Celaya'),
|
||||
backgroundColor: const Color(0xFF1B5E20),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_mostrarTodas ? Icons.visibility_off : Icons.visibility),
|
||||
tooltip: _mostrarTodas ? 'Solo mis rutas' : 'Ver todas',
|
||||
onPressed: () => setState(() => _mostrarTodas = !_mostrarTodas),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.my_location),
|
||||
onPressed: () => _mapController.move(_centro, 12.5),
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _cargarDatos),
|
||||
],
|
||||
),
|
||||
body: _cargando
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(flex: 3, child: _buildMapa()),
|
||||
_buildLeyenda(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapa() {
|
||||
return FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: const MapOptions(
|
||||
initialCenter: _centro,
|
||||
initialZoom: 12.5,
|
||||
minZoom: 11,
|
||||
maxZoom: 17,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.example.aplicacion_hack',
|
||||
maxZoom: 19,
|
||||
),
|
||||
PolylineLayer(polylines: _buildPolylines()),
|
||||
MarkerLayer(markers: _buildMarkers()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Polyline> _buildPolylines() {
|
||||
final list = <Polyline>[];
|
||||
for (final ruta in _todasLasRutas) {
|
||||
final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId);
|
||||
if (!_mostrarTodas && !esDelUsuario) continue;
|
||||
|
||||
final estado = _estadoRuta(ruta.routeId);
|
||||
final posActual = estado?.lastPositionId ?? 0;
|
||||
final seleccionada = _rutaSeleccionada == ruta.routeId;
|
||||
final grosorBase = esDelUsuario ? 5.0 : 2.0;
|
||||
final grosor = seleccionada ? grosorBase + 2 : grosorBase;
|
||||
|
||||
if (esDelUsuario && posActual > 1) {
|
||||
// Tramo recorrido
|
||||
final recorridos = ruta.puntos.take(posActual).toList();
|
||||
if (recorridos.length >= 2) {
|
||||
list.add(Polyline(
|
||||
points: recorridos,
|
||||
color: ruta.color,
|
||||
strokeWidth: grosor,
|
||||
));
|
||||
}
|
||||
// Tramo pendiente (punteado)
|
||||
final pendientes = ruta.puntos.skip(posActual - 1).toList();
|
||||
if (pendientes.length >= 2) {
|
||||
list.add(Polyline(
|
||||
points: pendientes,
|
||||
color: ruta.color.withValues(alpha: 0.35),
|
||||
strokeWidth: grosor - 1,
|
||||
pattern: StrokePattern.dashed(segments: [12, 8]),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
list.add(Polyline(
|
||||
points: ruta.puntos,
|
||||
color: esDelUsuario ? ruta.color : ruta.color.withValues(alpha: 0.2),
|
||||
strokeWidth: grosor,
|
||||
));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers() {
|
||||
final list = <Marker>[];
|
||||
|
||||
// Base / Relleno Sanitario
|
||||
list.add(Marker(
|
||||
point: LatLng(20.5111, -100.9037),
|
||||
width: 70,
|
||||
height: 36,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: const Text('🏭 Base',
|
||||
style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Container(width: 2, height: 4, color: Colors.grey.shade800),
|
||||
Container(
|
||||
width: 8, height: 8,
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
for (final ruta in _todasLasRutas) {
|
||||
final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId);
|
||||
if (!esDelUsuario) continue;
|
||||
|
||||
final estado = _estadoRuta(ruta.routeId);
|
||||
final posActual = estado?.lastPositionId ?? 0;
|
||||
|
||||
for (int i = 1; i < ruta.puntos.length - 1; i++) {
|
||||
final punto = ruta.puntos[i];
|
||||
final posId = i + 1;
|
||||
final esCamion = posId == posActual;
|
||||
|
||||
if (esCamion) {
|
||||
list.add(Marker(
|
||||
point: punto,
|
||||
width: 46,
|
||||
height: 54,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() =>
|
||||
_rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: ruta.color,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
boxShadow: [BoxShadow(color: ruta.color.withValues(alpha: 0.5), blurRadius: 6)],
|
||||
),
|
||||
child: Text(
|
||||
ruta.routeId.replaceAll('RUTA-', 'R'),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const Text('🚛', style: TextStyle(fontSize: 20)),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
} else {
|
||||
list.add(Marker(
|
||||
point: punto,
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() =>
|
||||
_rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: posId < posActual ? ruta.color : Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ruta.color, width: 2.5),
|
||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 2)],
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
Widget _buildLeyenda() {
|
||||
final rutasUsuario = _todasLasRutas
|
||||
.where((r) => _rutasDelUsuario.contains(r.routeId))
|
||||
.toList();
|
||||
|
||||
if (rutasUsuario.isEmpty) {
|
||||
return Container(
|
||||
height: 52,
|
||||
color: const Color(0xFF1B5E20),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('Sin rutas asignadas',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 96,
|
||||
color: const Color(0xFF1B5E20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 5, 0, 2),
|
||||
child: Text(
|
||||
_mostrarTodas ? 'Todas las rutas • Las tuyas están resaltadas' : 'Tus rutas — toca para centrar',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
itemCount: rutasUsuario.length,
|
||||
itemBuilder: (context, i) {
|
||||
final ruta = rutasUsuario[i];
|
||||
final estado = _estadoRuta(ruta.routeId);
|
||||
final posActual = estado?.lastPositionId ?? 0;
|
||||
final status = estado?.status ?? 'EN_RUTA';
|
||||
final seleccionada = _rutaSeleccionada == ruta.routeId;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() =>
|
||||
_rutaSeleccionada = seleccionada ? null : ruta.routeId);
|
||||
if (!seleccionada && ruta.puntos.length > 1) {
|
||||
_mapController.move(ruta.puntos[1], 13.5);
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: seleccionada
|
||||
? ruta.color.withValues(alpha: 0.3)
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: seleccionada ? ruta.color : Colors.white24,
|
||||
width: seleccionada ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 9, height: 9,
|
||||
decoration: BoxDecoration(color: ruta.color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(ruta.routeId,
|
||||
style: TextStyle(color: ruta.color, fontWeight: FontWeight.bold, fontSize: 11)),
|
||||
const SizedBox(width: 4),
|
||||
Text(status == 'COMPLETADO' ? '✅' : '🚛',
|
||||
style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
SizedBox(
|
||||
width: 115,
|
||||
child: Text(ruta.colonia,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 10),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
SizedBox(
|
||||
width: 115,
|
||||
height: 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: (posActual / 8).clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.white12,
|
||||
valueColor: AlwaysStoppedAnimation(ruta.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
posActual > 0 ? 'Pos. $posActual / 8' : 'Sin datos',
|
||||
style: TextStyle(color: ruta.color.withValues(alpha: 0.8), fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
538
HackOnLinces_app/aplicacion_hack/lib/screens/reporte_screen.dart
Normal file
538
HackOnLinces_app/aplicacion_hack/lib/screens/reporte_screen.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
// ================================================================
|
||||
// lib/screens/reporte_screen.dart
|
||||
// Pantalla para que el ciudadano envíe reportes manuales
|
||||
// ================================================================
|
||||
//
|
||||
// NAVEGAR DESDE home_screen.dart:
|
||||
// Navigator.pushNamed(context, '/reporte')
|
||||
//
|
||||
// AGREGAR EN main.dart:
|
||||
// '/reporte': (context) => const ReporteScreen(),
|
||||
//
|
||||
// SECCIONES:
|
||||
// 1. Formulario de nuevo reporte
|
||||
// 2. Historial de reportes del usuario
|
||||
// ================================================================
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// MODELOS
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
class ReporteInfo {
|
||||
final int reporteId;
|
||||
final String fecha;
|
||||
final String hora;
|
||||
final String colonia;
|
||||
final String tipo;
|
||||
final String? descripcion;
|
||||
final String estado;
|
||||
|
||||
ReporteInfo({
|
||||
required this.reporteId,
|
||||
required this.fecha,
|
||||
required this.hora,
|
||||
required this.colonia,
|
||||
required this.tipo,
|
||||
this.descripcion,
|
||||
required this.estado,
|
||||
});
|
||||
|
||||
factory ReporteInfo.fromJson(Map<String, dynamic> j) => ReporteInfo(
|
||||
reporteId: j['reporte_id'],
|
||||
fecha: j['fecha'],
|
||||
hora: j['hora'],
|
||||
colonia: j['colonia'],
|
||||
tipo: j['tipo'],
|
||||
descripcion: j['descripcion'],
|
||||
estado: j['estado'],
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// DATOS LOCALES
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
const _tiposReporte = [
|
||||
{'valor': 'CAMION_NO_PASO', 'label': 'El camión no pasó', 'emoji': '🚫', 'color': 0xFFC62828},
|
||||
{'valor': 'VOLUMEN_ALTO', 'label': 'Volumen inusualmente alto', 'emoji': '📦', 'color': 0xFFE65100},
|
||||
{'valor': 'BASURA_FUERA_HORARIO', 'label': 'Basura fuera de horario', 'emoji': '⏰', 'color': 0xFFF57F17},
|
||||
{'valor': 'OTRO', 'label': 'Otro problema', 'emoji': '📝', 'color': 0xFF37474F},
|
||||
];
|
||||
|
||||
const _colonias = [
|
||||
'Zona Centro', 'Las Arboledas', 'Trojes',
|
||||
'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes',
|
||||
];
|
||||
|
||||
// ================================================================
|
||||
// PANTALLA PRINCIPAL
|
||||
// ================================================================
|
||||
class ReporteScreen extends StatefulWidget {
|
||||
const ReporteScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReporteScreen> createState() => _ReporteScreenState();
|
||||
}
|
||||
|
||||
class _ReporteScreenState extends State<ReporteScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _baseUrl = 'http://192.168.198.55:8000';
|
||||
|
||||
late TabController _tabController;
|
||||
int? _usuarioId;
|
||||
|
||||
// Formulario
|
||||
String? _tipoSeleccionado;
|
||||
String? _coloniaSeleccionada;
|
||||
final _descController = TextEditingController();
|
||||
bool _enviando = false;
|
||||
|
||||
// Historial
|
||||
List<ReporteInfo> _reportes = [];
|
||||
bool _cargandoHistorial = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_cargarUsuario();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_descController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _cargarUsuario() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt('usuario_id');
|
||||
if (id != null && mounted) {
|
||||
setState(() => _usuarioId = id);
|
||||
_cargarHistorial();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cargarHistorial() async {
|
||||
if (_usuarioId == null) return;
|
||||
setState(() => _cargandoHistorial = true);
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse('$_baseUrl/api/reportes/usuario/$_usuarioId'))
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode == 200 && mounted) {
|
||||
final lista = json.decode(resp.body) as List;
|
||||
setState(() {
|
||||
_reportes = lista.map((r) => ReporteInfo.fromJson(r)).toList();
|
||||
_cargandoHistorial = false;
|
||||
});
|
||||
} else {
|
||||
if (mounted) setState(() => _cargandoHistorial = false);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _cargandoHistorial = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enviarReporte() async {
|
||||
if (_tipoSeleccionado == null) {
|
||||
_snack('Selecciona el tipo de problema.', error: true);
|
||||
return;
|
||||
}
|
||||
if (_coloniaSeleccionada == null) {
|
||||
_snack('Selecciona la colonia.', error: true);
|
||||
return;
|
||||
}
|
||||
if (_usuarioId == null) {
|
||||
_snack('No hay sesión activa.', error: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _enviando = true);
|
||||
|
||||
try {
|
||||
final resp = await http.post(
|
||||
Uri.parse('$_baseUrl/api/reportes?usuario_id=$_usuarioId'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'colonia': _coloniaSeleccionada,
|
||||
'tipo': _tipoSeleccionado,
|
||||
'descripcion': _descController.text.trim().isEmpty
|
||||
? null
|
||||
: _descController.text.trim(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (resp.statusCode == 200 && mounted) {
|
||||
_snack('✅ Reporte enviado correctamente. ¡Gracias!');
|
||||
setState(() {
|
||||
_tipoSeleccionado = null;
|
||||
_coloniaSeleccionada = null;
|
||||
});
|
||||
_descController.clear();
|
||||
_cargarHistorial();
|
||||
_tabController.animateTo(1); // ir al historial
|
||||
} else {
|
||||
final body = json.decode(resp.body);
|
||||
_snack(body['detail'] ?? 'Error al enviar el reporte.', error: true);
|
||||
}
|
||||
} catch (e) {
|
||||
_snack('Sin conexión al servidor.', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _enviando = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg, {bool error = false}) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: error ? Colors.red.shade700 : Colors.green.shade700,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BUILD
|
||||
// ================================================================
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('Reportes ciudadanos'),
|
||||
backgroundColor: const Color(0xFF1B5E20),
|
||||
foregroundColor: Colors.white,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white60,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.add_circle_outline), text: 'Nuevo reporte'),
|
||||
Tab(icon: Icon(Icons.history), text: 'Mis reportes'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildFormulario(), _buildHistorial()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// TAB 1: FORMULARIO
|
||||
// ================================================================
|
||||
Widget _buildFormulario() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Encabezado
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2E7D32).withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF2E7D32).withValues(alpha: 0.2)),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('📋 Envía un reporte',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Tu reporte se guarda en la base de datos y ayuda a mejorar '
|
||||
'la logística de recolección en tu colonia.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.black54, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_Label('¿Qué problema ocurrió?'),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Selector de tipo
|
||||
...(_tiposReporte.map((tipo) {
|
||||
final seleccionado = _tipoSeleccionado == tipo['valor'];
|
||||
final color = Color(tipo['color'] as int);
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _tipoSeleccionado = tipo['valor'] as String),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: seleccionado ? color.withValues(alpha: 0.1) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: seleccionado ? color : Colors.grey.shade200,
|
||||
width: seleccionado ? 2 : 1,
|
||||
),
|
||||
boxShadow: seleccionado
|
||||
? [BoxShadow(color: color.withValues(alpha: 0.15), blurRadius: 8)]
|
||||
: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 4)],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(tipo['emoji'] as String, style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tipo['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal,
|
||||
color: seleccionado ? color : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (seleccionado)
|
||||
Icon(Icons.check_circle, color: color, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
})),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_Label('¿En qué colonia?'),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Selector de colonia
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _coloniaSeleccionada,
|
||||
hint: const Text('Selecciona tu colonia'),
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.location_city_outlined),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
items: _colonias
|
||||
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _coloniaSeleccionada = v),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_Label('Descripción adicional (opcional)'),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _descController,
|
||||
maxLines: 4,
|
||||
maxLength: 300,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Describe el problema con más detalle...',
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Botón enviar
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _enviando ? null : _enviarReporte,
|
||||
icon: _enviando
|
||||
? const SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send_rounded),
|
||||
label: Text(_enviando ? 'Enviando...' : 'Enviar reporte'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2E7D32),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'Tu reporte es anónimo para el operador y ayuda\na mejorar el servicio de recolección.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// TAB 2: HISTORIAL
|
||||
// ================================================================
|
||||
Widget _buildHistorial() {
|
||||
if (_cargandoHistorial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_reportes.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 72, color: Colors.grey.shade300),
|
||||
const SizedBox(height: 16),
|
||||
const Text('No has enviado reportes aún',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Usa la pestaña anterior para reportar un problema.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _cargarHistorial,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _reportes.length,
|
||||
itemBuilder: (context, i) => _TarjetaReporte(reporte: _reportes[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// WIDGETS AUXILIARES
|
||||
// ================================================================
|
||||
|
||||
class _Label extends StatelessWidget {
|
||||
final String text;
|
||||
const _Label(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
|
||||
class _TarjetaReporte extends StatelessWidget {
|
||||
final ReporteInfo reporte;
|
||||
const _TarjetaReporte({required this.reporte});
|
||||
|
||||
Map<String, dynamic> get _tipoInfo {
|
||||
return _tiposReporte.firstWhere(
|
||||
(t) => t['valor'] == reporte.tipo,
|
||||
orElse: () => {'label': reporte.tipo, 'emoji': '📝', 'color': 0xFF37474F},
|
||||
);
|
||||
}
|
||||
|
||||
Color get _colorEstado => reporte.estado == 'ATENDIDO'
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFFE65100);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = _tipoInfo;
|
||||
final color = Color(info['color'] as int);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)
|
||||
],
|
||||
border: Border(left: BorderSide(color: color, width: 4)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(info['emoji'] as String, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(info['label'] as String,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: color, fontSize: 14)),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorEstado.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
reporte.estado == 'ATENDIDO' ? '✅ Atendido' : '⏳ Pendiente',
|
||||
style: TextStyle(
|
||||
color: _colorEstado, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_city_outlined, size: 14, color: Colors.grey.shade500),
|
||||
const SizedBox(width: 4),
|
||||
Text(reporte.colonia,
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||
const SizedBox(width: 16),
|
||||
Icon(Icons.calendar_today_outlined, size: 14, color: Colors.grey.shade500),
|
||||
const SizedBox(width: 4),
|
||||
Text('${reporte.fecha} ${reporte.hora.substring(0, 5)}',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
if (reporte.descripcion != null && reporte.descripcion!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
reporte.descripcion!,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
class RouteListScreen extends StatefulWidget {
|
||||
const RouteListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RouteListScreen> createState() => _RouteListScreenState();
|
||||
}
|
||||
|
||||
class _RouteListScreenState extends State<RouteListScreen> {
|
||||
final ApiService _apiService = ApiService();
|
||||
bool _cargando = true;
|
||||
bool _avanzando = false;
|
||||
String? _error;
|
||||
List<RouteInfo> _rutas = [];
|
||||
int? _usuarioId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarUsuarioYRutas();
|
||||
}
|
||||
|
||||
Future<void> _cargarUsuarioYRutas() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final usuarioId = prefs.getInt('usuario_id');
|
||||
|
||||
if (usuarioId == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error =
|
||||
'No se encontró sesión activa. Por favor inicia sesión de nuevo.';
|
||||
_cargando = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_usuarioId = usuarioId;
|
||||
await _cargarRutas();
|
||||
}
|
||||
|
||||
Future<void> _cargarRutas() async {
|
||||
setState(() {
|
||||
_cargando = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
if (_usuarioId == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error =
|
||||
'No se encontró sesión activa. Por favor inicia sesión de nuevo.';
|
||||
_cargando = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final rutas = await _apiService.obtenerRutas(_usuarioId!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_rutas = rutas;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'No se pudieron cargar las rutas. Verifica el backend.';
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cargando = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _simularAvance(String routeId) async {
|
||||
setState(() {
|
||||
_avanzando = true;
|
||||
});
|
||||
|
||||
if (_usuarioId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('No se encontró sesión activa. Inicia sesión de nuevo.'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final rutaActualizada =
|
||||
await _apiService.avanzarRuta(routeId, _usuarioId!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final index = _rutas.indexWhere((r) => r.routeId == routeId);
|
||||
if (index >= 0) {
|
||||
_rutas[index] = rutaActualizada;
|
||||
}
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'La ruta ${rutaActualizada.routeId} avanzó al siguiente tramo.'),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error al simular avance: $e'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_avanzando = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _colorEstado(String status) {
|
||||
switch (status) {
|
||||
case 'COMPLETADO':
|
||||
return Colors.blue.shade600;
|
||||
case 'EN_RUTA':
|
||||
return Colors.green.shade600;
|
||||
default:
|
||||
return Colors.orange.shade600;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Rutas y estado del camión'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pushNamed(context, '/mapa'),
|
||||
icon: const Icon(Icons.map_rounded),
|
||||
tooltip: 'Ver mapa de rutas',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _cargarRutas,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _cargando
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 72, color: Colors.redAccent),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: _cargarRutas,
|
||||
child: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _cargarRutas,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Simulación de rutas',
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Esta pantalla muestra el estado actual de cada camión y permite simular el siguiente tramo de la ruta para pruebas.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
..._rutas.map((ruta) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ruta.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: Text(ruta.status),
|
||||
backgroundColor:
|
||||
_colorEstado(ruta.status)
|
||||
.withValues(alpha: 0.15),
|
||||
labelStyle: TextStyle(
|
||||
color: _colorEstado(ruta.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
runSpacing: 6,
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildTag('Ruta: ${ruta.routeId}',
|
||||
Colors.grey.shade200),
|
||||
_buildTag(
|
||||
'Posición: ${ruta.lastPositionId}',
|
||||
Colors.blue.shade50),
|
||||
_buildTag(
|
||||
ruta.gpsOk
|
||||
? 'GPS OK'
|
||||
: 'GPS desconectado',
|
||||
ruta.gpsOk
|
||||
? Colors.green.shade50
|
||||
: Colors.red.shade50,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Último reporte: ${ruta.lastTimestamp}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700,
|
||||
fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: ruta.status == 'COMPLETADO' ||
|
||||
_avanzando
|
||||
? null
|
||||
: () => _simularAvance(ruta.routeId),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Simular avance'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Información relevante',
|
||||
style: TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'• Separa correctamente orgánicos, reciclables y no reciclables.',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'• No mezcles basura húmeda con envases secos y mantén los líquidos controlados.',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'• Saca tu basura cuando el camión esté cerca: así evitamos plagas y malos olores.',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'• Usa bolsas resistentes y cierra bien los residuos antes de ponerlos en la acera.',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
483
HackOnLinces_app/aplicacion_hack/lib/services/api_service.dart
Normal file
483
HackOnLinces_app/aplicacion_hack/lib/services/api_service.dart
Normal file
@@ -0,0 +1,483 @@
|
||||
// ================================================================
|
||||
// lib/services/api_service.dart (v2)
|
||||
// Servicio de comunicación con el backend FastAPI
|
||||
// ================================================================
|
||||
//
|
||||
// CAMBIOS v2:
|
||||
// - LoginResponse incluye 'nombre' del usuario
|
||||
// - ActualizarPassword requiere password_actual + password_nuevo
|
||||
// - Nuevos métodos: obtenerDashboard, historialPosiciones,
|
||||
// resumenRutas, estadisticasColonias
|
||||
// ================================================================
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// MODELOS
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
class ETAInfo {
|
||||
final int usuarioId;
|
||||
final String colonia;
|
||||
final String rutaNombre;
|
||||
final String rutaStatus;
|
||||
final bool gpsOk;
|
||||
final String etaTexto;
|
||||
final int etaMinutos;
|
||||
final String mensajePreventivo;
|
||||
|
||||
ETAInfo({
|
||||
required this.usuarioId,
|
||||
required this.colonia,
|
||||
required this.rutaNombre,
|
||||
required this.rutaStatus,
|
||||
required this.gpsOk,
|
||||
required this.etaTexto,
|
||||
required this.etaMinutos,
|
||||
required this.mensajePreventivo,
|
||||
});
|
||||
|
||||
factory ETAInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ETAInfo(
|
||||
usuarioId: json['usuario_id'],
|
||||
colonia: json['colonia'],
|
||||
rutaNombre: json['ruta_nombre'] ?? '',
|
||||
rutaStatus: json['ruta_status'] ?? '',
|
||||
gpsOk: json['gps_ok'] ?? true,
|
||||
etaTexto: json['eta_texto'],
|
||||
etaMinutos: json['eta_minutos'],
|
||||
mensajePreventivo: json['mensaje_preventivo'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DireccionInfo {
|
||||
final String colonia;
|
||||
final String direccion;
|
||||
|
||||
DireccionInfo({required this.colonia, required this.direccion});
|
||||
|
||||
factory DireccionInfo.fromJson(Map<String, dynamic> json) {
|
||||
return DireccionInfo(colonia: json['colonia'], direccion: json['direccion']);
|
||||
}
|
||||
}
|
||||
|
||||
class UsuarioInfo {
|
||||
final int usuarioId;
|
||||
final String nombre;
|
||||
final String email;
|
||||
final List<DireccionInfo> direcciones;
|
||||
|
||||
UsuarioInfo({
|
||||
required this.usuarioId,
|
||||
required this.nombre,
|
||||
required this.email,
|
||||
required this.direcciones,
|
||||
});
|
||||
|
||||
factory UsuarioInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UsuarioInfo(
|
||||
usuarioId: json['usuario_id'],
|
||||
nombre: json['nombre'],
|
||||
email: json['email'],
|
||||
direcciones: List<Map<String, dynamic>>.from(json['direcciones'])
|
||||
.map(DireccionInfo.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RouteInfo {
|
||||
final String routeId;
|
||||
final String name;
|
||||
final String status;
|
||||
final int lastPositionId;
|
||||
final String lastTimestamp;
|
||||
final bool gpsOk;
|
||||
|
||||
RouteInfo({
|
||||
required this.routeId,
|
||||
required this.name,
|
||||
required this.status,
|
||||
required this.lastPositionId,
|
||||
required this.lastTimestamp,
|
||||
required this.gpsOk,
|
||||
});
|
||||
|
||||
factory RouteInfo.fromJson(Map<String, dynamic> json) {
|
||||
return RouteInfo(
|
||||
routeId: json['route_id'],
|
||||
name: json['name'],
|
||||
status: json['status'],
|
||||
lastPositionId: json['last_position_id'],
|
||||
lastTimestamp: json['last_timestamp'],
|
||||
gpsOk: json['gps_ok'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Posición GPS individual de la ruta de un camión.
|
||||
class PosicionGPS {
|
||||
final int positionId;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final int speed;
|
||||
final String timestamp;
|
||||
final bool esActual; // true = aquí está el camión ahora
|
||||
|
||||
PosicionGPS({
|
||||
required this.positionId,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.speed,
|
||||
required this.timestamp,
|
||||
required this.esActual,
|
||||
});
|
||||
|
||||
factory PosicionGPS.fromJson(Map<String, dynamic> json) {
|
||||
return PosicionGPS(
|
||||
positionId: json['position_id'],
|
||||
lat: (json['lat'] as num).toDouble(),
|
||||
lng: (json['lng'] as num).toDouble(),
|
||||
speed: json['speed'],
|
||||
timestamp: json['timestamp'],
|
||||
esActual: json['es_actual'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detalle de una ruta para el dashboard.
|
||||
class RutaDetalle {
|
||||
final String routeId;
|
||||
final String name;
|
||||
final String status;
|
||||
final int truckId;
|
||||
final int posicionActual;
|
||||
final int totalPosiciones;
|
||||
final double porcentajeCompletado;
|
||||
final int etaMinutos;
|
||||
final bool gpsOk;
|
||||
final int usuariosEnRuta;
|
||||
|
||||
RutaDetalle({
|
||||
required this.routeId,
|
||||
required this.name,
|
||||
required this.status,
|
||||
required this.truckId,
|
||||
required this.posicionActual,
|
||||
required this.totalPosiciones,
|
||||
required this.porcentajeCompletado,
|
||||
required this.etaMinutos,
|
||||
required this.gpsOk,
|
||||
required this.usuariosEnRuta,
|
||||
});
|
||||
|
||||
factory RutaDetalle.fromJson(Map<String, dynamic> json) {
|
||||
return RutaDetalle(
|
||||
routeId: json['route_id'],
|
||||
name: json['name'],
|
||||
status: json['status'],
|
||||
truckId: json['truck_id'],
|
||||
posicionActual: json['posicion_actual'],
|
||||
totalPosiciones: json['total_posiciones'],
|
||||
porcentajeCompletado: (json['porcentaje_completado'] as num).toDouble(),
|
||||
etaMinutos: json['eta_minutos'],
|
||||
gpsOk: json['gps_ok'],
|
||||
usuariosEnRuta: json['usuarios_en_ruta'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Respuesta completa del dashboard de operador.
|
||||
class DashboardInfo {
|
||||
final int totalRutas;
|
||||
final int rutasEnProgreso;
|
||||
final int rutasCompletadas;
|
||||
final int totalUsuarios;
|
||||
final int usuariosConToken;
|
||||
final double coberturaNotificaciones;
|
||||
final List<RutaDetalle> rutas;
|
||||
|
||||
DashboardInfo({
|
||||
required this.totalRutas,
|
||||
required this.rutasEnProgreso,
|
||||
required this.rutasCompletadas,
|
||||
required this.totalUsuarios,
|
||||
required this.usuariosConToken,
|
||||
required this.coberturaNotificaciones,
|
||||
required this.rutas,
|
||||
});
|
||||
|
||||
factory DashboardInfo.fromJson(Map<String, dynamic> json) {
|
||||
return DashboardInfo(
|
||||
totalRutas: json['total_rutas'],
|
||||
rutasEnProgreso: json['rutas_en_progreso'],
|
||||
rutasCompletadas: json['rutas_completadas'],
|
||||
totalUsuarios: json['total_usuarios'],
|
||||
usuariosConToken: json['usuarios_con_token'],
|
||||
coberturaNotificaciones: (json['cobertura_notificaciones'] as num).toDouble(),
|
||||
rutas: List<Map<String, dynamic>>.from(json['rutas'])
|
||||
.map(RutaDetalle.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Estadísticas de una colonia.
|
||||
class ColoniaEstadistica {
|
||||
final String colonia;
|
||||
final String routeId;
|
||||
final String rutaNombre;
|
||||
final String horario;
|
||||
final int totalUsuarios;
|
||||
final int usuariosConNotificaciones;
|
||||
|
||||
ColoniaEstadistica({
|
||||
required this.colonia,
|
||||
required this.routeId,
|
||||
required this.rutaNombre,
|
||||
required this.horario,
|
||||
required this.totalUsuarios,
|
||||
required this.usuariosConNotificaciones,
|
||||
});
|
||||
|
||||
factory ColoniaEstadistica.fromJson(Map<String, dynamic> json) {
|
||||
return ColoniaEstadistica(
|
||||
colonia: json['colonia'],
|
||||
routeId: json['route_id'],
|
||||
rutaNombre: json['ruta_nombre'],
|
||||
horario: json['horario'],
|
||||
totalUsuarios: json['total_usuarios'],
|
||||
usuariosConNotificaciones: json['usuarios_con_notificaciones'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// CLASE PRINCIPAL: ApiService
|
||||
// ----------------------------------------------------------------
|
||||
class ApiService {
|
||||
// ============================================================
|
||||
// BASE URL — Cambia solo esta línea para apuntar a otro entorno
|
||||
// Android emulator local: http://10.0.2.2:8000
|
||||
// Dispositivo físico (red local): http://192.168.X.X:8000
|
||||
// ============================================================
|
||||
static const String _baseUrl = 'http://192.168.198.55:8000';
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// HELPER PRIVADO: maneja errores HTTP de forma consistente
|
||||
// ----------------------------------------------------------------
|
||||
Never _throwError(http.Response response) {
|
||||
Map<String, dynamic> body = {};
|
||||
try {
|
||||
body = json.decode(response.body);
|
||||
} catch (_) {}
|
||||
final detail = body['detail'] ?? response.body;
|
||||
throw Exception(detail);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// AUTENTICACIÓN
|
||||
// ================================================================
|
||||
|
||||
/// Login con email y contraseña. Retorna [usuarioId, nombre].
|
||||
Future<Map<String, dynamic>> loginConCorreo(String email, String password) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/usuarios/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'email': email.trim().toLowerCase(), 'password': password}),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return {'usuario_id': data['usuario_id'], 'nombre': data['nombre']};
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
Future<int> registrarUsuario(
|
||||
String nombre,
|
||||
String email,
|
||||
String password,
|
||||
String direccion,
|
||||
String colonia,
|
||||
) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/usuarios/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'nombre': nombre.trim(),
|
||||
'email': email.trim().toLowerCase(),
|
||||
'password': password,
|
||||
'colonia': colonia,
|
||||
'direccion': direccion.trim(),
|
||||
}),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body)['usuario_id'];
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// USUARIOS
|
||||
// ================================================================
|
||||
|
||||
Future<ETAInfo> obtenerETA(int usuarioId) async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/eta/$usuarioId'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ETAInfo.fromJson(json.decode(response.body));
|
||||
} else if (response.statusCode == 404) {
|
||||
throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?');
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
Future<List<String>> obtenerColonias() async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/colonias'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return List<String>.from(json.decode(response.body)['colonias']);
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
Future<UsuarioInfo> obtenerUsuario(int usuarioId) async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/usuarios/$usuarioId'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UsuarioInfo.fromJson(json.decode(response.body));
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
Future<void> agregarDireccion(int usuarioId, String colonia, String direccion) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/direcciones'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'colonia': colonia, 'direccion': direccion.trim()}),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) _throwError(response);
|
||||
}
|
||||
|
||||
/// Actualiza contraseña. Requiere la contraseña actual como confirmación.
|
||||
Future<void> actualizarPassword(int usuarioId, String passwordActual, String passwordNuevo) async {
|
||||
final response = await http.put(
|
||||
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/password'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'password_actual': passwordActual,
|
||||
'password_nuevo': passwordNuevo,
|
||||
}),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) _throwError(response);
|
||||
}
|
||||
|
||||
Future<void> registrarFcmToken(int usuarioId, String fcmToken) async {
|
||||
final response = await http.put(
|
||||
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'fcm_token': fcmToken}),
|
||||
).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Error registrando FCM token: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// RUTAS
|
||||
// ================================================================
|
||||
|
||||
Future<List<RouteInfo>> obtenerRutas(int usuarioId) async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/rutas?usuario_id=$usuarioId'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return List<Map<String, dynamic>>.from(json.decode(response.body)['rutas'])
|
||||
.map(RouteInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
Future<RouteInfo> avanzarRuta(String routeId, int usuarioId) async {
|
||||
final response = await http
|
||||
.post(Uri.parse('$_baseUrl/api/rutas/$routeId/avanzar?usuario_id=$usuarioId'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return RouteInfo.fromJson(json.decode(response.body));
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// VISUALIZACIÓN — NUEVOS EN v2
|
||||
// ================================================================
|
||||
|
||||
/// Dashboard global: estado de todas las rutas + métricas de usuarios.
|
||||
Future<DashboardInfo> obtenerDashboard() async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/dashboard'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return DashboardInfo.fromJson(json.decode(response.body));
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
/// Historial de posiciones GPS de una ruta, con la posición actual marcada.
|
||||
Future<List<PosicionGPS>> historialPosiciones(String routeId) async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/rutas/$routeId/posiciones'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return List<Map<String, dynamic>>.from(json.decode(response.body))
|
||||
.map(PosicionGPS.fromJson)
|
||||
.toList();
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
/// Vista rápida y ligera de todas las rutas. Ideal para polling frecuente.
|
||||
Future<List<Map<String, dynamic>>> resumenRutas() async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/rutas/resumen'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return List<Map<String, dynamic>>.from(json.decode(response.body)['rutas']);
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
|
||||
/// Estadísticas por colonia: usuarios y cobertura de notificaciones.
|
||||
Future<List<ColoniaEstadistica>> estadisticasColonias() async {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/api/estadisticas/colonias'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return List<Map<String, dynamic>>.from(json.decode(response.body))
|
||||
.map(ColoniaEstadistica.fromJson)
|
||||
.toList();
|
||||
}
|
||||
_throwError(response);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user