diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 82e2b73..cb220b0 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -26,33 +26,44 @@ def register(body: RegisterRequest): if not body.email and not body.phone: raise HTTPException(status_code=400, detail="Se requiere email o teléfono") + if len(body.password) < 6: + raise HTTPException(status_code=400, detail="La contraseña debe tener al menos 6 caracteres.") + try: if body.email: resp = supabase.auth.sign_up({"email": body.email, "password": body.password}) else: resp = supabase.auth.sign_up({"phone": body.phone, "password": body.password}) except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + error_msg = str(e) + if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower(): + raise HTTPException(status_code=400, detail="El usuario ya está registrado en el sistema de autenticación.") + if "signups are disabled" in error_msg.lower(): + raise HTTPException(status_code=400, detail="El registro de nuevos usuarios está deshabilitado temporalmente.") + if "rate limit" in error_msg.lower(): + raise HTTPException(status_code=400, detail="Límite de registros excedido por seguridad. Desactiva la confirmación de correos en Supabase o intenta más tarde.") + raise HTTPException(status_code=400, detail=error_msg) auth_user = resp.user if not auth_user: raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth") # Crear entrada en public.users con el rol elegido - supabase_admin.table("users").upsert( - { - "id": str(auth_user.id), - "email": body.email, - "phone": body.phone, - "role": body.role, - } - ).execute() + try: + supabase_admin.table("users").upsert( + { + "id": str(auth_user.id), + "role": body.role, + } + ).execute() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}") - # Si no hubo sesión (email confirmation pendiente) devolvemos token vacío con aviso + # Si no hubo sesión (email confirmation pendiente) if not resp.session: raise HTTPException( - status_code=202, - detail="Cuenta creada. Confirma tu email antes de iniciar sesión.", + status_code=400, + detail="Cuenta creada. Revisa tu correo para confirmar tu email antes de iniciar sesión.", ) return TokenResponse( diff --git a/claude (1).md b/claude (1).md index 2ccf286..6e42ebf 100644 --- a/claude (1).md +++ b/claude (1).md @@ -1,3 +1,9 @@ +try { + final response = await dio.post('/auth/register', data: {...}); +} on DioException catch (e) { + // Aquí puedes ver el JSON real que envía tu backend en e.response?.data + print('Error del backend: ${e.response?.data}'); +} # Contexto del Proyecto — Sistema de Recolección Inteligente y Privada de Residuos > **Documento para compartir con asistentes de IA (Claude, ChatGPT, etc.)** diff --git a/recolecta_app/assets/data/separation_guide.json b/recolecta_app/assets/data/separation_guide.json new file mode 100644 index 0000000..47cb6ac --- /dev/null +++ b/recolecta_app/assets/data/separation_guide.json @@ -0,0 +1,64 @@ +{ + "version": "1.0.0", + "categorias": [ + { + "id": "organicos", + "nombre": "Orgánicos", + "color": "#4CAF50", + "icono": "eco", + "descripcion": "Residuos de origen vegetal y animal que se descomponen naturalmente.", + "ejemplos": [ + { "nombre": "Cáscaras de frutas y verduras", "acepta": true }, + { "nombre": "Restos de comida", "acepta": true }, + { "nombre": "Café y filtros de papel", "acepta": true }, + { "nombre": "Pañales y toallas sanitarias", "acepta": false, "razon": "Van en sanitarios" } + ], + "consejo": "Puedes compostar los orgánicos en casa para abonar plantas." + }, + { + "id": "reciclables", + "nombre": "Reciclables", + "color": "#2196F3", + "icono": "recycling", + "descripcion": "Materiales que pueden transformarse en nuevos productos.", + "ejemplos": [ + { "nombre": "Botellas de PET", "acepta": true }, + { "nombre": "Latas de aluminio", "acepta": true }, + { "nombre": "Papel y cartón limpios", "acepta": true }, + { "nombre": "Vidrio (sin romper)", "acepta": true }, + { "nombre": "Papel con grasa (cajas de pizza)", "acepta": false, "razon": "Va en orgánicos o sanitarios" } + ], + "consejo": "Enjuaga los envases antes de separarlos. El plástico sucio NO se recicla." + }, + { + "id": "sanitarios", + "nombre": "Sanitarios", + "color": "#FF5722", + "icono": "delete", + "descripcion": "Residuos que representan riesgo sanitario. No se reciclan ni compostan.", + "ejemplos": [ + { "nombre": "Pañales", "acepta": true }, + { "nombre": "Toallas sanitarias", "acepta": true }, + { "nombre": "Papel de baño usado", "acepta": true }, + { "nombre": "Mascarillas y guantes", "acepta": true }, + { "nombre": "Jeringas (sin tapa)", "acepta": false, "razon": "Son residuos RPBI — llevar a centro de salud" } + ], + "consejo": "Envuelve los sanitarios en bolsa cerrada antes de desechar." + }, + { + "id": "especiales", + "nombre": "Especiales (RAEE / Peligrosos)", + "color": "#9C27B0", + "icono": "warning", + "descripcion": "Residuos que requieren manejo especial por su toxicidad o composición.", + "ejemplos": [ + { "nombre": "Pilas y baterías", "acepta": false, "razon": "Llevar a puntos de acopio (farmacias, tiendas departamentales)" }, + { "nombre": "Celulares y electrónicos", "acepta": false, "razon": "Puntos RAEE o centros de reciclaje certificados" }, + { "nombre": "Aceite de cocina usado", "acepta": false, "razon": "Llevar en botella cerrada a punto de acopio" }, + { "nombre": "Medicamentos vencidos", "acepta": false, "razon": "Farmacias participantes o centros de salud" }, + { "nombre": "Pintura y solventes", "acepta": false, "razon": "Centro de acopio municipal" } + ], + "consejo": "NUNCA tires pilas o electrónicos en la basura regular. Contaminarás suelo y agua." + } + ] +} diff --git a/recolecta_app/lib/app/app.dart b/recolecta_app/lib/app/app.dart index 4b581a4..e09db89 100644 --- a/recolecta_app/lib/app/app.dart +++ b/recolecta_app/lib/app/app.dart @@ -1,288 +1,19 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../core/network/api_client.dart'; -import '../core/models/auth_state.dart'; -import '../core/services/auth_controller.dart'; -import '../core/storage/secure_storage.dart'; -import 'bootstrap.dart' as bootstrap; -import '../features/auth/login_page.dart'; -import '../features/auth/register_page.dart'; -import '../features/addresses/new_address_page.dart'; - -final routerProvider = Provider((ref) { - // ValueNotifier used as refreshListenable so GoRouter re-evaluates redirect - // without recreating the router (which would unmount widgets mid-request). - final notifier = ValueNotifier(0); - ref.listen>(authControllerProvider, (prev, next) { - notifier.value++; - }); - ref.onDispose(notifier.dispose); - - return GoRouter( - initialLocation: '/login', - refreshListenable: notifier, - redirect: (context, state) { - final authSnapshot = ref.read(authControllerProvider); - final isAuthenticated = - authSnapshot.asData?.value.isAuthenticated ?? false; - final location = state.matchedLocation; - final isAuthRoute = - location == '/login' || location == '/register'; - - if (authSnapshot.isLoading) return null; - if (!isAuthenticated && !isAuthRoute) return '/login'; - if (isAuthenticated && isAuthRoute) return '/home'; - return null; - }, - routes: [ - GoRoute( - path: '/login', - name: 'login', - builder: (context, state) => const LoginPage(), - ), - GoRoute( - path: '/register', - name: 'register', - builder: (context, state) => const RegisterPage(), - ), - GoRoute( - path: '/home', - name: 'home', - builder: (context, state) => const HomePage(), - ), - GoRoute( - path: '/addresses/new', - name: 'addresses-new', - builder: (context, state) => const NewAddressPage(), - ), - GoRoute( - path: '/status', - name: 'status', - builder: (context, state) => const StatusPage(), - ), - ], - ); -}); +import 'package:recolecta_app/core/router/app_router.dart'; +import 'package:recolecta_app/core/theme/app_theme.dart'; class RecolectaApp extends ConsumerWidget { const RecolectaApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final bootstrapState = ref.watch(bootstrap.bootstrapProvider); - - return bootstrapState.when( - loading: () => const MaterialApp( - debugShowCheckedModeBanner: false, - home: BootstrapLoadingPage(), - ), - error: (error, stackTrace) => MaterialApp( - debugShowCheckedModeBanner: false, - home: BootstrapErrorPage(error: error), - ), - data: (_) => MaterialApp.router( - debugShowCheckedModeBanner: false, - title: 'Recolecta', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6F78)), - scaffoldBackgroundColor: const Color(0xFFF4F7F6), - useMaterial3: true, - ), - routerConfig: ref.watch(routerProvider), - ), - ); - } -} - -class BootstrapLoadingPage extends StatelessWidget { - const BootstrapLoadingPage({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } -} - -class BootstrapErrorPage extends StatelessWidget { - const BootstrapErrorPage({super.key, required this.error}); - - final Object error; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48), - const SizedBox(height: 16), - const Text( - 'No se pudo cargar la configuración inicial.', - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - error.toString(), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - ); - } -} - -class HomePage extends ConsumerWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final dio = ref.read(apiClientProvider); - final storage = ref.read(secureStorageProvider); - final baseUrl = dio.options.baseUrl; - final authState = ref.watch(authControllerProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Recolecta'), - actions: [ - IconButton( - onPressed: authState.isLoading - ? null - : () async { - await ref.read(authControllerProvider.notifier).logout(); - if (context.mounted) { - context.go('/login'); - } - }, - icon: const Icon(Icons.logout), - tooltip: 'Salir', - ), - IconButton( - onPressed: () => context.goNamed('status'), - icon: const Icon(Icons.route), - tooltip: 'Estado', - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(24), - child: ListView( - children: [ - const Text( - 'Bootstrap listo', - style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - Text( - 'La app ya carga .env, Riverpod y GoRouter para la base del MVP.', - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/addresses/new'), - child: const Text('Agregar domicilio'), - ), - const SizedBox(height: 24), - _InfoCard(title: 'API base URL', value: baseUrl, icon: Icons.cloud), - const SizedBox(height: 16), - _InfoCard( - title: 'Secure Storage', - value: storage.runtimeType.toString(), - icon: Icons.lock, - ), - const SizedBox(height: 16), - _ImageCard( - title: 'Widget listo para caché de imágenes', - imageUrl: - 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80', - ), - ], - ), - ), - ); - } -} - -class StatusPage extends StatelessWidget { - const StatusPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Estado')), - body: const Padding( - padding: EdgeInsets.all(24), - child: Text( - 'Aquí después se conectarán ETA, notificaciones, ruta asignada y métricas de privacidad.', - ), - ), - ); - } -} - -class _InfoCard extends StatelessWidget { - const _InfoCard({ - required this.title, - required this.value, - required this.icon, - }); - - final String title; - final String value; - final IconData icon; - - @override - Widget build(BuildContext context) { - return Card( - elevation: 0, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(value), - ), - ); - } -} - -class _ImageCard extends StatelessWidget { - const _ImageCard({required this.title, required this.imageUrl}); - - final String title; - final String imageUrl; - - @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CachedNetworkImage( - imageUrl: imageUrl, - height: 180, - width: double.infinity, - fit: BoxFit.cover, - placeholder: (context, url) => const SizedBox( - height: 180, - child: Center(child: CircularProgressIndicator()), - ), - errorWidget: (context, url, error) => const SizedBox( - height: 180, - child: Center(child: Icon(Icons.image_not_supported)), - ), - ), - Padding(padding: const EdgeInsets.all(16), child: Text(title)), - ], - ), + final router = ref.watch(routerProvider); + return MaterialApp.router( + title: 'Recolecta App', + theme: AppTheme.lightTheme, + debugShowCheckedModeBanner: false, + routerConfig: router, ); } } diff --git a/viewsv1/views/lib/models/models.dart b/recolecta_app/lib/core/models/ui_models.dart similarity index 59% rename from viewsv1/views/lib/models/models.dart rename to recolecta_app/lib/core/models/ui_models.dart index 3313d2c..e3b659c 100644 --- a/viewsv1/views/lib/models/models.dart +++ b/recolecta_app/lib/core/models/ui_models.dart @@ -1,67 +1,50 @@ -// ── Usuario ────────────────────────────────────────────────────────────────── -class UserModel { +// ── Usuario (presentación UI) ───────────────────────────────────────────────── +class UIUserModel { final String id; final String nombre; final String apellido; final String email; final String telefono; - final List casas; + final String role; - const UserModel({ + const UIUserModel({ required this.id, required this.nombre, required this.apellido, required this.email, required this.telefono, - this.casas = const [], + this.role = 'citizen', }); - String get nombreCompleto => '$nombre $apellido'; - String get iniciales => - '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' - .toUpperCase(); - - UserModel copyWith({ - String? nombre, - String? apellido, - String? email, - String? telefono, - List? casas, - }) { - return UserModel( - id: id, - nombre: nombre ?? this.nombre, - apellido: apellido ?? this.apellido, - email: email ?? this.email, - telefono: telefono ?? this.telefono, - casas: casas ?? this.casas, - ); + String get nombreCompleto => '$nombre $apellido'.trim(); + String get iniciales { + final n = nombre.isNotEmpty ? nombre[0] : ''; + final a = apellido.isNotEmpty ? apellido[0] : ''; + return '$n$a'.toUpperCase(); } + + bool get isAdmin => role == 'admin'; } -// ── Casa ───────────────────────────────────────────────────────────────────── -class HouseModel { +// ── Casa / Domicilio ────────────────────────────────────────────────────────── +class UIHouseModel { final String id; final String alias; final String calle; final String colonia; - final String codigoPostal; - final double latitud; - final double longitud; + final String? routeId; final int radioAlertaMetros; final bool alertaCercana; final bool alertaMedia; final bool recordatorioDiario; final bool activa; - const HouseModel({ + const UIHouseModel({ required this.id, this.alias = 'Casa principal', required this.calle, required this.colonia, - required this.codigoPostal, - required this.latitud, - required this.longitud, + this.routeId, this.radioAlertaMetros = 200, this.alertaCercana = true, this.alertaMedia = false, @@ -69,29 +52,25 @@ class HouseModel { this.activa = true, }); - String get direccionCompleta => '$calle, Col. $colonia, C.P. $codigoPostal'; + String get direccionCompleta => '$calle, Col. $colonia'; - HouseModel copyWith({ + UIHouseModel copyWith({ String? alias, String? calle, String? colonia, - String? codigoPostal, - double? latitud, - double? longitud, + String? routeId, int? radioAlertaMetros, bool? alertaCercana, bool? alertaMedia, bool? recordatorioDiario, bool? activa, }) { - return HouseModel( + return UIHouseModel( id: id, alias: alias ?? this.alias, calle: calle ?? this.calle, colonia: colonia ?? this.colonia, - codigoPostal: codigoPostal ?? this.codigoPostal, - latitud: latitud ?? this.latitud, - longitud: longitud ?? this.longitud, + routeId: routeId ?? this.routeId, radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros, alertaCercana: alertaCercana ?? this.alertaCercana, alertaMedia: alertaMedia ?? this.alertaMedia, @@ -99,38 +78,22 @@ class HouseModel { activa: activa ?? this.activa, ); } -} -// ── Camión ─────────────────────────────────────────────────────────────────── -class TruckLocation { - final String id; - final String ruta; - final double latitud; - final double longitud; - final DateTime ultimaActualizacion; - final bool enServicio; - - const TruckLocation({ - required this.id, - required this.ruta, - required this.latitud, - required this.longitud, - required this.ultimaActualizacion, - this.enServicio = true, - }); - - String get tiempoActualizacion { - final diff = DateTime.now().difference(ultimaActualizacion); - if (diff.inSeconds < 60) return 'Hace ${diff.inSeconds} s'; - if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min'; - return 'Hace ${diff.inHours} h'; + factory UIHouseModel.fromJson(Map json) { + return UIHouseModel( + id: json['id'] as String? ?? '', + alias: json['label'] as String? ?? 'Casa principal', + calle: json['calle'] as String? ?? '', + colonia: json['colonia'] as String? ?? '', + routeId: json['route_id'] as String?, + ); } } // ── Alerta ─────────────────────────────────────────────────────────────────── enum TipoAlerta { cercana, media, recordatorio } -class AlertaModel { +class UIAlertaModel { final String id; final TipoAlerta tipo; final double distanciaMetros; @@ -138,7 +101,7 @@ class AlertaModel { final String direccionCasa; final bool leida; - const AlertaModel({ + const UIAlertaModel({ required this.id, required this.tipo, required this.distanciaMetros, @@ -155,7 +118,6 @@ class AlertaModel { } String get tiempoEstimadoTexto { - // ~5 km/h velocidad promedio del camión final segundos = (distanciaMetros / (5000 / 3600)).round(); if (segundos < 60) return 'Menos de 1 min'; final minutos = (segundos / 60).ceil(); @@ -166,13 +128,9 @@ class AlertaModel { final ahora = DateTime.now(); final hoy = DateTime(ahora.year, ahora.month, ahora.day); final fechaDia = DateTime(fecha.year, fecha.month, fecha.day); - - if (fechaDia == hoy) { - return 'Hoy, ${_formatHora(fecha)}'; - } + if (fechaDia == hoy) return 'Hoy, ${_formatHora(fecha)}'; final ayer = hoy.subtract(const Duration(days: 1)); if (fechaDia == ayer) return 'Ayer, ${_formatHora(fecha)}'; - const dias = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; const meses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; return '${dias[fecha.weekday - 1]} ${fecha.day} ${meses[fecha.month - 1]}, ${_formatHora(fecha)}'; diff --git a/recolecta_app/lib/core/network/api_client.dart b/recolecta_app/lib/core/network/api_client.dart index 43bbe89..203d208 100644 --- a/recolecta_app/lib/core/network/api_client.dart +++ b/recolecta_app/lib/core/network/api_client.dart @@ -7,10 +7,12 @@ import '../constants/auth_constants.dart'; import '../storage/secure_storage.dart'; final apiClientProvider = Provider((ref) { - final defaultBaseUrl = kIsWeb - ? 'http://localhost:8000' - : 'http://10.0.2.2:8000'; - final baseUrl = dotenv.env['API_BASE_URL'] ?? defaultBaseUrl; + final envUrl = dotenv.env['API_BASE_URL']; + final baseUrl = kIsWeb + ? (envUrl != null && envUrl.contains('localhost') + ? envUrl + : 'http://localhost:8000') + : (envUrl ?? 'http://10.0.2.2:8000'); final secureStorage = ref.read(secureStorageProvider); final dio = Dio( diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart new file mode 100644 index 0000000..f9f26b0 --- /dev/null +++ b/recolecta_app/lib/core/router/app_router.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:recolecta_app/features/admin/admin_shell.dart'; +import 'package:recolecta_app/features/auth/login_page.dart'; +import 'package:recolecta_app/features/driver/driver_shell.dart'; +import 'package:recolecta_app/features/driver/screens/driver_collections_screen.dart'; +import 'package:recolecta_app/features/driver/screens/driver_home_screen.dart'; +import 'package:recolecta_app/features/driver/screens/driver_incident_screen.dart'; +import 'package:recolecta_app/features/feedback/feedback_screen.dart'; +import 'package:recolecta_app/features/home/citizen_home_screen.dart'; +import 'package:recolecta_app/features/home/citizen_shell.dart'; +import 'package:recolecta_app/features/separation_guide/screens/category_detail_screen.dart'; +import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart'; +import 'package:recolecta_app/core/services/auth_controller.dart'; + +// Mock Admin Screens +class AdminDashboardScreen extends StatelessWidget { + const AdminDashboardScreen({super.key}); + @override + Widget build(BuildContext context) => + const Scaffold(body: Center(child: Text('Admin Dashboard'))); +} + +class AdminRouteDetailScreen extends StatelessWidget { + const AdminRouteDetailScreen({super.key, required this.routeId}); + final String routeId; + @override + Widget build(BuildContext context) => + Scaffold(body: Center(child: Text('Admin Route Detail: $routeId'))); +} + +class AdminReassignScreen extends StatelessWidget { + const AdminReassignScreen({super.key, required this.routeId}); + final String routeId; + @override + Widget build(BuildContext context) => + Scaffold(body: Center(child: Text('Admin Reassign: $routeId'))); +} + +final routerProvider = Provider((ref) { + final authState = ref.watch(authControllerProvider); + + return GoRouter( + initialLocation: '/login', + redirect: (BuildContext context, GoRouterState state) { + final isAuthenticated = authState.value?.isAuthenticated ?? false; + final role = authState.value?.userRole; + + final isLoggingIn = state.matchedLocation == '/login'; + + if (!isAuthenticated) { + return isLoggingIn ? null : '/login'; + } + + if (isLoggingIn) { + switch (role) { + case 'admin': + return '/admin'; + case 'driver': + return '/driver'; + case 'citizen': + return '/home'; + default: + return '/login'; + } + } + + return null; + }, + routes: [ + GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + ShellRoute( + builder: (context, state, child) => AdminShell(child: child), + routes: [ + GoRoute( + path: '/admin', + builder: (context, state) => const AdminDashboardScreen(), + routes: [ + GoRoute( + path: 'routes/:routeId', + builder: (context, state) => AdminRouteDetailScreen( + routeId: state.pathParameters['routeId']!, + ), + ), + GoRoute( + path: 'reassign/:routeId', + builder: (context, state) => AdminReassignScreen( + routeId: state.pathParameters['routeId']!, + ), + ), + ], + ), + ], + ), + ShellRoute( + builder: (context, state, child) => DriverShell(child: child), + routes: [ + GoRoute( + path: '/driver', + builder: (context, state) => const DriverHomeScreen(), + ), + GoRoute( + path: '/driver/collections', + builder: (context, state) => const DriverCollectionsScreen(), + ), + GoRoute( + path: '/driver/incident', + builder: (context, state) => const DriverIncidentScreen(), + ), + ], + ), + ShellRoute( + builder: (context, state, child) => CitizenShell(child: child), + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const CitizenHomeScreen(), + ), + GoRoute( + path: '/feedback', + builder: (context, state) => const FeedbackScreen(), + ), + GoRoute( + path: '/guide', + builder: (context, state) => const SeparationGuideScreen(), + routes: [ + GoRoute( + path: ':categoryId', + builder: (context, state) => CategoryDetailScreen( + categoryId: state.pathParameters['categoryId']!, + ), + ), + ], + ), + ], + ), + ], + ); +}); diff --git a/viewsv1/views/lib/theme/app_theme.dart b/recolecta_app/lib/core/theme/app_theme.dart similarity index 51% rename from viewsv1/views/lib/theme/app_theme.dart rename to recolecta_app/lib/core/theme/app_theme.dart index 567d82d..843031c 100644 --- a/viewsv1/views/lib/theme/app_theme.dart +++ b/recolecta_app/lib/core/theme/app_theme.dart @@ -1,41 +1,38 @@ import 'package:flutter/material.dart'; class AppTheme { - // ── Colores principales ────────────────────────────────────────────────── - static const Color primary = Color(0xFF1D9E75); - static const Color primaryDark = Color(0xFF0F6E56); - static const Color primaryLight = Color(0xFFE1F5EE); - static const Color primaryMid = Color(0xFF9FE1CB); + static const Color primary = Color(0xFF1D9E75); + static const Color primaryDark = Color(0xFF0F6E56); + static const Color primaryLight = Color(0xFFE1F5EE); + static const Color primaryMid = Color(0xFF9FE1CB); - static const Color blue = Color(0xFF185FA5); - static const Color blueLight = Color(0xFFE6F1FB); + static const Color blue = Color(0xFF185FA5); + static const Color blueLight = Color(0xFFE6F1FB); - static const Color amber = Color(0xFF854F0B); - static const Color amberLight = Color(0xFFFAEEDA); + static const Color amber = Color(0xFF854F0B); + static const Color amberLight = Color(0xFFFAEEDA); - static const Color danger = Color(0xFFE24B4A); - static const Color dangerLight = Color(0xFFFCEBEB); + static const Color danger = Color(0xFFE24B4A); + static const Color dangerLight = Color(0xFFFCEBEB); - static const Color textPrimary = Color(0xFF1A1A1A); - static const Color textSecondary = Color(0xFF6B7280); - static const Color textHint = Color(0xFFAAAAAA); + static const Color textPrimary = Color(0xFF1A1A1A); + static const Color textSecondary = Color(0xFF6B7280); + static const Color textHint = Color(0xFFAAAAAA); - static const Color surface = Color(0xFFFFFFFF); - static const Color background = Color(0xFFF5F7F5); - static const Color border = Color(0xFFE5E7EB); - static const Color borderLight = Color(0xFFF0F2F0); + static const Color surface = Color(0xFFFFFFFF); + static const Color background = Color(0xFFF5F7F5); + static const Color border = Color(0xFFE5E7EB); + static const Color borderLight = Color(0xFFF0F2F0); - // ── Radios ─────────────────────────────────────────────────────────────── - static const double radiusSm = 8.0; - static const double radiusMd = 12.0; - static const double radiusLg = 16.0; - static const double radiusXl = 24.0; + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 24.0; static const double radiusFull = 100.0; - // ── Sombras ────────────────────────────────────────────────────────────── static List get cardShadow => [ BoxShadow( - color: Colors.black.withOpacity(0.06), + color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4), ), @@ -43,22 +40,19 @@ class AppTheme { static List get softShadow => [ BoxShadow( - color: Colors.black.withOpacity(0.04), + color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2), ), ]; - // ── ThemeData ──────────────────────────────────────────────────────────── static ThemeData get lightTheme => ThemeData( useMaterial3: true, - fontFamily: 'SF Pro Display', colorScheme: ColorScheme.fromSeed( seedColor: primary, primary: primary, secondary: primaryDark, surface: surface, - background: background, ), scaffoldBackgroundColor: background, appBarTheme: const AppBarTheme( @@ -67,10 +61,12 @@ class AppTheme { elevation: 0, centerTitle: false, titleTextStyle: TextStyle( + inherit: false, fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white, ), + iconTheme: IconThemeData(color: Colors.white), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -81,9 +77,15 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), ), ), ), @@ -95,10 +97,6 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), ), ), inputDecorationTheme: InputDecorationTheme( @@ -117,6 +115,14 @@ class AppTheme { borderRadius: BorderRadius.circular(radiusSm), borderSide: const BorderSide(color: primary, width: 1.5), ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: danger, width: 1.5), + ), labelStyle: const TextStyle(color: textSecondary, fontSize: 13), hintStyle: const TextStyle(color: textHint, fontSize: 13), ), diff --git a/viewsv1/views/lib/widgets/widgets.dart b/recolecta_app/lib/core/widgets/app_widgets.dart similarity index 79% rename from viewsv1/views/lib/widgets/widgets.dart rename to recolecta_app/lib/core/widgets/app_widgets.dart index b4e2137..78640c6 100644 --- a/viewsv1/views/lib/widgets/widgets.dart +++ b/recolecta_app/lib/core/widgets/app_widgets.dart @@ -2,36 +2,42 @@ import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; // ── Badge de estado ─────────────────────────────────────────────────────────── -class StatusBadge extends StatelessWidget { +class AppStatusBadge extends StatelessWidget { final String label; final Color backgroundColor; final Color textColor; - const StatusBadge({ + const AppStatusBadge({ super.key, required this.label, this.backgroundColor = AppTheme.primaryLight, this.textColor = AppTheme.primaryDark, }); - factory StatusBadge.green(String label) => StatusBadge( + factory AppStatusBadge.green(String label) => AppStatusBadge( label: label, backgroundColor: AppTheme.primaryLight, textColor: AppTheme.primaryDark, ); - factory StatusBadge.amber(String label) => StatusBadge( + factory AppStatusBadge.amber(String label) => AppStatusBadge( label: label, backgroundColor: AppTheme.amberLight, textColor: AppTheme.amber, ); - factory StatusBadge.gray(String label) => StatusBadge( + factory AppStatusBadge.gray(String label) => AppStatusBadge( label: label, backgroundColor: const Color(0xFFF1EFE8), textColor: const Color(0xFF5F5E5A), ); + factory AppStatusBadge.danger(String label) => AppStatusBadge( + label: label, + backgroundColor: AppTheme.dangerLight, + textColor: AppTheme.danger, + ); + @override Widget build(BuildContext context) { return Container( @@ -77,10 +83,7 @@ class AppCard extends StatelessWidget { decoration: BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all( - color: borderColor ?? AppTheme.border, - width: 0.5, - ), + border: Border.all(color: borderColor ?? AppTheme.border, width: 0.5), boxShadow: AppTheme.softShadow, ), child: child, @@ -90,13 +93,13 @@ class AppCard extends StatelessWidget { } // ── Fila de información con ícono ───────────────────────────────────────────── -class InfoRow extends StatelessWidget { +class AppInfoRow extends StatelessWidget { final IconData icon; final String label; final String value; final Widget? trailing; - const InfoRow({ + const AppInfoRow({ super.key, required this.icon, required this.label, @@ -142,7 +145,7 @@ class InfoRow extends StatelessWidget { ], ), ), - if (trailing != null) trailing!, + ?trailing, ], ), ); @@ -150,7 +153,7 @@ class InfoRow extends StatelessWidget { } // ── Campo de formulario ─────────────────────────────────────────────────────── -class FormField extends StatelessWidget { +class AppFormField extends StatelessWidget { final String label; final String? hint; final TextEditingController? controller; @@ -159,8 +162,9 @@ class FormField extends StatelessWidget { final String? initialValue; final Widget? suffix; final int? maxLines; + final String? Function(String?)? validator; - const FormField({ + const AppFormField({ super.key, required this.label, this.hint, @@ -170,6 +174,7 @@ class FormField extends StatelessWidget { this.initialValue, this.suffix, this.maxLines = 1, + this.validator, }); @override @@ -189,11 +194,9 @@ class FormField extends StatelessWidget { obscureText: obscureText, keyboardType: keyboardType, maxLines: maxLines, + validator: validator, style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary), - decoration: InputDecoration( - hintText: hint, - suffixIcon: suffix, - ), + decoration: InputDecoration(hintText: hint, suffixIcon: suffix), ), ], ); @@ -201,11 +204,11 @@ class FormField extends StatelessWidget { } // ── Sección con título ──────────────────────────────────────────────────────── -class SectionTitle extends StatelessWidget { +class AppSectionTitle extends StatelessWidget { final String title; final Widget? action; - const SectionTitle({super.key, required this.title, this.action}); + const AppSectionTitle({super.key, required this.title, this.action}); @override Widget build(BuildContext context) { @@ -231,12 +234,12 @@ class SectionTitle extends StatelessWidget { } // ── Toggle con label ────────────────────────────────────────────────────────── -class LabeledSwitch extends StatelessWidget { +class AppLabeledSwitch extends StatelessWidget { final String label; final bool value; final ValueChanged onChanged; - const LabeledSwitch({ + const AppLabeledSwitch({ super.key, required this.label, required this.value, @@ -257,7 +260,7 @@ class LabeledSwitch extends StatelessWidget { Switch.adaptive( value: value, onChanged: onChanged, - activeColor: AppTheme.primary, + activeTrackColor: AppTheme.primary, ), ], ), @@ -266,7 +269,7 @@ class LabeledSwitch extends StatelessWidget { } // ── Ítem de menú ────────────────────────────────────────────────────────────── -class MenuTile extends StatelessWidget { +class AppMenuTile extends StatelessWidget { final IconData icon; final String title; final String? subtitle; @@ -275,7 +278,7 @@ class MenuTile extends StatelessWidget { final Color? titleColor; final Widget? trailing; - const MenuTile({ + const AppMenuTile({ super.key, required this.icon, required this.title, @@ -301,8 +304,7 @@ class MenuTile extends StatelessWidget { ), child: Row( children: [ - Icon(icon, - color: iconColor ?? AppTheme.primary, size: 20), + Icon(icon, color: iconColor ?? AppTheme.primary, size: 20), const SizedBox(width: 12), Expanded( child: Column( @@ -332,6 +334,52 @@ class MenuTile extends StatelessWidget { } } +// ── Tarjeta de formulario con ícono ─────────────────────────────────────────── +class AppFormCard extends StatelessWidget { + final IconData icon; + final String title; + final Widget child; + + const AppFormCard({ + super.key, + required this.icon, + required this.title, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: AppTheme.primary, size: 18), + const SizedBox(width: 8), + Text(title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + // ── Bottom Nav Bar ──────────────────────────────────────────────────────────── class AppBottomNav extends StatelessWidget { final int currentIndex; @@ -356,14 +404,14 @@ class AppBottomNav extends StatelessWidget { unselectedFontSize: 11, elevation: 12, items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.map_outlined), - activeIcon: Icon(Icons.map), - label: 'Mapa', - ), BottomNavigationBarItem( icon: Icon(Icons.notifications_outlined), activeIcon: Icon(Icons.notifications), + label: 'ETA', + ), + BottomNavigationBarItem( + icon: Icon(Icons.history_outlined), + activeIcon: Icon(Icons.history), label: 'Alertas', ), BottomNavigationBarItem( diff --git a/recolecta_app/lib/features/admin/admin_screen.dart b/recolecta_app/lib/features/admin/admin_screen.dart new file mode 100644 index 0000000..a3298a2 --- /dev/null +++ b/recolecta_app/lib/features/admin/admin_screen.dart @@ -0,0 +1,421 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; + +// ── Modelos locales ─────────────────────────────────────────────────────────── +enum TruckStatus { disponible, enRuta, mantenimiento, detenido } + +extension TruckStatusX on TruckStatus { + String get label => switch (this) { + TruckStatus.disponible => 'Disponible', + TruckStatus.enRuta => 'En ruta', + TruckStatus.mantenimiento => 'Mantenimiento', + TruckStatus.detenido => 'Detenido', + }; + + AppStatusBadge get badge => switch (this) { + TruckStatus.disponible => AppStatusBadge.green(label), + TruckStatus.enRuta => AppStatusBadge.amber(label), + TruckStatus.mantenimiento => AppStatusBadge.gray(label), + TruckStatus.detenido => AppStatusBadge.gray(label), + }; +} + +class _AdminUser { + final String id, nombre, apellido, email, telefono; + const _AdminUser({ + required this.id, + required this.nombre, + required this.apellido, + required this.email, + required this.telefono, + }); + String get nombreCompleto => '$nombre $apellido'; + String get iniciales => + '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' + .toUpperCase(); + _AdminUser copyWith({String? nombre, String? apellido, String? email, String? telefono}) => + _AdminUser(id: id, nombre: nombre ?? this.nombre, apellido: apellido ?? this.apellido, email: email ?? this.email, telefono: telefono ?? this.telefono); +} + +class _AdminRoute { + final String id, nombre, zona; + final bool activa; + const _AdminRoute({required this.id, required this.nombre, required this.zona, this.activa = true}); + _AdminRoute copyWith({String? nombre, String? zona, bool? activa}) => + _AdminRoute(id: id, nombre: nombre ?? this.nombre, zona: zona ?? this.zona, activa: activa ?? this.activa); +} + +class _AdminTruck { + final String id, placas, modelo, conductor, rutaId; + final TruckStatus status; + const _AdminTruck({ + required this.id, required this.placas, required this.modelo, + required this.conductor, required this.status, required this.rutaId, + }); + _AdminTruck copyWith({String? placas, String? modelo, String? conductor, TruckStatus? status, String? rutaId}) => + _AdminTruck(id: id, placas: placas ?? this.placas, modelo: modelo ?? this.modelo, conductor: conductor ?? this.conductor, status: status ?? this.status, rutaId: rutaId ?? this.rutaId); +} + +// ── Pantalla ────────────────────────────────────────────────────────────────── +class AdminScreen extends StatefulWidget { + const AdminScreen({super.key}); + + @override + State createState() => _AdminScreenState(); +} + +class _AdminScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + int _activeTab = 0; + + final List<_AdminUser> _usuarios = [ + const _AdminUser(id: 'u-01', nombre: 'Laura', apellido: 'Gómez', email: 'laura@recolecta.com', telefono: '+52 461 987 1234'), + const _AdminUser(id: 'u-02', nombre: 'Miguel', apellido: 'Sánchez', email: 'miguel@recolecta.com', telefono: '+52 461 123 7890'), + ]; + + final List<_AdminRoute> _rutas = [ + const _AdminRoute(id: 'RUTA-01', nombre: 'Ruta Norte', zona: 'Zona Norte'), + const _AdminRoute(id: 'RUTA-02', nombre: 'Ruta Sur', zona: 'Zona Sur', activa: false), + ]; + + final List<_AdminTruck> _camiones = [ + const _AdminTruck(id: 't-01', placas: 'GTO-101', modelo: 'Volvo FH', conductor: 'Javier Pérez', status: TruckStatus.enRuta, rutaId: 'RUTA-01'), + const _AdminTruck(id: 't-02', placas: 'GTO-103', modelo: 'Mercedes 1830', conductor: 'Ana Díaz', status: TruckStatus.disponible, rutaId: 'RUTA-02'), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this) + ..addListener(() { + if (!_tabController.indexIsChanging) { + setState(() => _activeTab = _tabController.index); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Panel de administración'), + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(text: 'Usuarios'), + Tab(text: 'Rutas'), + Tab(text: 'Camiones'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildUsersTab(), + _buildRoutesTab(), + _buildTrucksTab(), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + if (_activeTab == 0) _showUserForm(); + else if (_activeTab == 1) _showRouteForm(); + else _showTruckForm(); + }, + backgroundColor: AppTheme.primary, + label: Text(_activeTab == 0 ? 'Nuevo usuario' : _activeTab == 1 ? 'Nueva ruta' : 'Nuevo camión'), + icon: const Icon(Icons.add), + ), + ); + } + + // ── Tab usuarios ──────────────────────────────────────────────────────────── + Widget _buildUsersTab() { + if (_usuarios.isEmpty) return _emptyState('No hay usuarios registrados.'); + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _usuarios.length, + separatorBuilder: (_, i) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final u = _usuarios[i]; + return AppCard( + child: Row( + children: [ + CircleAvatar( + backgroundColor: AppTheme.primaryLight, + foregroundColor: AppTheme.primary, + child: Text(u.iniciales), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(u.nombreCompleto, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(u.email, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + Text(u.telefono, style: const TextStyle(fontSize: 13)), + ], + ), + ), + IconButton(icon: const Icon(Icons.edit_outlined, color: AppTheme.primary), onPressed: () => _showUserForm(user: u)), + IconButton(icon: const Icon(Icons.delete_outline, color: AppTheme.danger), onPressed: () => _confirmDelete('usuario', () => setState(() => _usuarios.removeWhere((x) => x.id == u.id)))), + ], + ), + ); + }, + ); + } + + // ── Tab rutas ─────────────────────────────────────────────────────────────── + Widget _buildRoutesTab() { + if (_rutas.isEmpty) return _emptyState('No hay rutas registradas.'); + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _rutas.length, + separatorBuilder: (_, i) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final r = _rutas[i]; + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(r.nombre, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))), + r.activa ? AppStatusBadge.green('Activa') : AppStatusBadge.gray('Inactiva'), + ], + ), + const SizedBox(height: 6), + Text(r.zona, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon(onPressed: () => _showRouteForm(route: r), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmDelete('ruta', () => setState(() => _rutas.removeWhere((x) => x.id == r.id))), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + ), + ], + ), + ], + ), + ); + }, + ); + } + + // ── Tab camiones ──────────────────────────────────────────────────────────── + Widget _buildTrucksTab() { + if (_camiones.isEmpty) return _emptyState('No hay camiones registrados.'); + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _camiones.length, + separatorBuilder: (_, i) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final t = _camiones[i]; + final ruta = _rutas.firstWhere((r) => r.id == t.rutaId, orElse: () => const _AdminRoute(id: '', nombre: 'Sin ruta', zona: '')); + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(t.placas, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))), + t.status.badge, + ], + ), + const SizedBox(height: 6), + Text('${t.modelo} · ${t.conductor}', style: const TextStyle(fontSize: 13)), + Text('Ruta: ${ruta.nombre}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon(onPressed: () => _showTruckForm(truck: t), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmDelete('camión', () => setState(() => _camiones.removeWhere((x) => x.id == t.id))), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _emptyState(String msg) => Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(msg, textAlign: TextAlign.center, style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary)))); + + // ── Confirmación de borrado ───────────────────────────────────────────────── + void _confirmDelete(String tipo, VoidCallback onConfirm) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: Text('Eliminar $tipo'), + content: Text('¿Deseas eliminar este $tipo?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () { onConfirm(); Navigator.pop(ctx); }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); + } + + // ── Formulario usuario ────────────────────────────────────────────────────── + void _showUserForm({_AdminUser? user}) { + final nombreCtrl = TextEditingController(text: user?.nombre); + final apellidoCtrl = TextEditingController(text: user?.apellido); + final emailCtrl = TextEditingController(text: user?.email); + final telefonoCtrl = TextEditingController(text: user?.telefono); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: Text(user == null ? 'Nuevo usuario' : 'Editar usuario'), + content: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre')), + TextField(controller: apellidoCtrl, decoration: const InputDecoration(labelText: 'Apellido')), + TextField(controller: emailCtrl, decoration: const InputDecoration(labelText: 'Correo'), keyboardType: TextInputType.emailAddress), + TextField(controller: telefonoCtrl, decoration: const InputDecoration(labelText: 'Teléfono'), keyboardType: TextInputType.phone), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () { + final nuevo = _AdminUser(id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), apellido: apellidoCtrl.text.trim(), email: emailCtrl.text.trim(), telefono: telefonoCtrl.text.trim()); + setState(() { + if (user == null) { _usuarios.add(nuevo); } + else { final idx = _usuarios.indexWhere((x) => x.id == user.id); if (idx >= 0) _usuarios[idx] = nuevo; } + }); + Navigator.pop(ctx); + }, + child: Text(user == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ); + } + + // ── Formulario ruta ───────────────────────────────────────────────────────── + void _showRouteForm({_AdminRoute? route}) { + final nombreCtrl = TextEditingController(text: route?.nombre); + final zonaCtrl = TextEditingController(text: route?.zona); + bool activa = route?.activa ?? true; + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setInner) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre de ruta')), + TextField(controller: zonaCtrl, decoration: const InputDecoration(labelText: 'Zona')), + Row(children: [ + const Expanded(child: Text('Ruta activa')), + Switch.adaptive(value: activa, onChanged: (v) => setInner(() => activa = v)), + ]), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () { + final nueva = _AdminRoute(id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), zona: zonaCtrl.text.trim(), activa: activa); + setState(() { + if (route == null) { _rutas.add(nueva); } + else { final idx = _rutas.indexWhere((x) => x.id == route.id); if (idx >= 0) _rutas[idx] = nueva; } + }); + Navigator.pop(ctx); + }, + child: Text(route == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ), + ); + } + + // ── Formulario camión ─────────────────────────────────────────────────────── + void _showTruckForm({_AdminTruck? truck}) { + final placasCtrl = TextEditingController(text: truck?.placas); + final modeloCtrl = TextEditingController(text: truck?.modelo); + final conductorCtrl = TextEditingController(text: truck?.conductor); + TruckStatus status = truck?.status ?? TruckStatus.disponible; + String selectedRuta = truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : ''); + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setInner) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'), + content: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: placasCtrl, decoration: const InputDecoration(labelText: 'Placas')), + TextField(controller: modeloCtrl, decoration: const InputDecoration(labelText: 'Modelo')), + TextField(controller: conductorCtrl, decoration: const InputDecoration(labelText: 'Conductor')), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedRuta.isEmpty ? null : selectedRuta, + decoration: const InputDecoration(labelText: 'Ruta'), + items: _rutas.map((r) => DropdownMenuItem(value: r.id, child: Text(r.nombre))).toList(), + onChanged: (v) { if (v != null) setInner(() => selectedRuta = v); }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: status, + decoration: const InputDecoration(labelText: 'Estatus'), + items: TruckStatus.values.map((s) => DropdownMenuItem(value: s, child: Text(s.label))).toList(), + onChanged: (v) { if (v != null) setInner(() => status = v); }, + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () { + final nuevo = _AdminTruck(id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}', placas: placasCtrl.text.trim(), modelo: modeloCtrl.text.trim(), conductor: conductorCtrl.text.trim(), status: status, rutaId: selectedRuta); + setState(() { + if (truck == null) { _camiones.add(nuevo); } + else { final idx = _camiones.indexWhere((x) => x.id == truck.id); if (idx >= 0) _camiones[idx] = nuevo; } + }); + Navigator.pop(ctx); + }, + child: Text(truck == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/admin/admin_shell.dart b/recolecta_app/lib/features/admin/admin_shell.dart new file mode 100644 index 0000000..73029f1 --- /dev/null +++ b/recolecta_app/lib/features/admin/admin_shell.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AdminShell extends StatefulWidget { + const AdminShell({super.key, required this.child}); + final Widget child; + + @override + State createState() => _AdminShellState(); +} + +class _AdminShellState extends State { + int _currentIndex = 0; + + void _onTap(int index) { + setState(() { + _currentIndex = index; + }); + switch (index) { + case 0: + context.go('/admin'); + break; + case 1: + // Placeholder for future routes + break; + case 2: + // Placeholder for future routes + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: _onTap, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.dashboard), + label: 'Dashboard', + ), + BottomNavigationBarItem(icon: Icon(Icons.route), label: 'Rutas'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Choferes'), + ], + ), + ); + } +} diff --git a/viewsv1/views/lib/screens/alerts_screen.dart b/recolecta_app/lib/features/alerts/alerts_screen.dart similarity index 66% rename from viewsv1/views/lib/screens/alerts_screen.dart rename to recolecta_app/lib/features/alerts/alerts_screen.dart index 7dd0df5..d9b29a7 100644 --- a/viewsv1/views/lib/screens/alerts_screen.dart +++ b/recolecta_app/lib/features/alerts/alerts_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../models/models.dart'; -import '../widgets/widgets.dart' as w; +import '../../core/theme/app_theme.dart'; +import '../../core/models/ui_models.dart'; +import '../../core/widgets/app_widgets.dart'; class AlertsScreen extends StatefulWidget { const AlertsScreen({super.key}); @@ -11,8 +11,7 @@ class AlertsScreen extends StatefulWidget { } class _AlertsScreenState extends State { - // Alerta activa de ejemplo - final AlertaModel? _alertaActiva = AlertaModel( + final UIAlertaModel _alertaActiva = UIAlertaModel( id: 'alerta-001', tipo: TipoAlerta.cercana, distanciaMetros: 180, @@ -21,9 +20,8 @@ class _AlertsScreenState extends State { leida: false, ); - // Historial de ejemplo - final List _historial = [ - AlertaModel( + final List _historial = [ + UIAlertaModel( id: 'h-001', tipo: TipoAlerta.cercana, distanciaMetros: 200, @@ -31,7 +29,7 @@ class _AlertsScreenState extends State { direccionCasa: 'Av. Insurgentes 245', leida: true, ), - AlertaModel( + UIAlertaModel( id: 'h-002', tipo: TipoAlerta.cercana, distanciaMetros: 200, @@ -39,15 +37,16 @@ class _AlertsScreenState extends State { direccionCasa: 'Av. Insurgentes 245', leida: true, ), - AlertaModel( + UIAlertaModel( id: 'h-003', tipo: TipoAlerta.cercana, distanciaMetros: 200, - fecha: DateTime.now().subtract(const Duration(days: 4, hours: 1, minutes: 30)), + fecha: DateTime.now().subtract( + const Duration(days: 4, hours: 1, minutes: 30)), direccionCasa: 'Av. Insurgentes 245', leida: true, ), - AlertaModel( + UIAlertaModel( id: 'h-004', tipo: TipoAlerta.cercana, distanciaMetros: 200, @@ -73,23 +72,17 @@ class _AlertsScreenState extends State { ), body: RefreshIndicator( color: AppTheme.primary, - onRefresh: () async { - await Future.delayed(const Duration(milliseconds: 800)); - }, + onRefresh: () async => + Future.delayed(const Duration(milliseconds: 800)), child: ListView( padding: const EdgeInsets.all(16), children: [ - // ── Alerta activa ─────────────────────────────────────────── - if (_alertaActiva != null) ...[ - _AlertaActivaCard(alerta: _alertaActiva!), - const SizedBox(height: 20), - ], - - // ── Historial ──────────────────────────────────────────────── + _AlertaActivaCard(alerta: _alertaActiva), + const SizedBox(height: 20), if (_historial.isEmpty) - _EmptyState() + const _EmptyState() else ...[ - w.SectionTitle(title: 'Historial de alertas'), + const AppSectionTitle(title: 'Historial de alertas'), ..._historial.map((a) => _AlertaHistorialItem(alerta: a)), ], ], @@ -99,9 +92,9 @@ class _AlertsScreenState extends State { } } -// ── Tarjeta de alerta activa ────────────────────────────────────────────────── +// ── Alerta activa ───────────────────────────────────────────────────────────── class _AlertaActivaCard extends StatelessWidget { - final AlertaModel alerta; + final UIAlertaModel alerta; const _AlertaActivaCard({required this.alerta}); @override @@ -119,7 +112,6 @@ class _AlertaActivaCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Row( children: [ Container( @@ -149,7 +141,8 @@ class _AlertaActivaCard extends StatelessWidget { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppTheme.primary, borderRadius: BorderRadius.circular(20), @@ -162,15 +155,9 @@ class _AlertaActivaCard extends StatelessWidget { ), ], ), - const SizedBox(height: 16), - - // Distancia - Text( - 'El camión se encuentra a', - style: const TextStyle( - fontSize: 13, color: AppTheme.primaryDark), - ), + const Text('El camión se encuentra a', + style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -185,51 +172,41 @@ class _AlertaActivaCard extends StatelessWidget { const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(bottom: 6), - child: Text( - 'de tu casa en ${alerta.direccionCasa}', - style: const TextStyle( - fontSize: 13, color: AppTheme.primaryDark), - ), + child: Text('de tu casa en ${alerta.direccionCasa}', + style: const TextStyle( + fontSize: 13, color: AppTheme.primaryDark)), ), ], ), - const SizedBox(height: 14), - - // Tiempo estimado Row( children: [ const Text('Llegada estimada:', - style: TextStyle( - fontSize: 12, color: AppTheme.primaryDark)), + style: + TextStyle(fontSize: 12, color: AppTheme.primaryDark)), const SizedBox(width: 6), - Text( - alerta.tiempoEstimadoTexto, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppTheme.primary), - ), + Text(alerta.tiempoEstimadoTexto, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primary)), ], ), - const SizedBox(height: 8), - - // Barra de progreso ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progreso, - backgroundColor: AppTheme.primaryMid.withOpacity(0.4), - valueColor: const AlwaysStoppedAnimation(AppTheme.primary), + backgroundColor: + AppTheme.primaryMid.withValues(alpha: 0.4), + valueColor: + const AlwaysStoppedAnimation(AppTheme.primary), minHeight: 7, ), ), - const SizedBox(height: 4), - - Row( - children: const [ + const Row( + children: [ Text('Lejos', style: TextStyle(fontSize: 10, color: AppTheme.primary)), Spacer(), @@ -237,33 +214,6 @@ class _AlertaActivaCard extends StatelessWidget { style: TextStyle(fontSize: 10, color: AppTheme.primary)), ], ), - - const SizedBox(height: 14), - - // Botón ver en mapa - GestureDetector( - onTap: () {}, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.map_outlined, color: Colors.white, size: 16), - SizedBox(width: 6), - Text('Ver en el mapa', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white)), - ], - ), - ), - ), ], ), ); @@ -272,7 +222,7 @@ class _AlertaActivaCard extends StatelessWidget { // ── Ítem de historial ───────────────────────────────────────────────────────── class _AlertaHistorialItem extends StatelessWidget { - final AlertaModel alerta; + final UIAlertaModel alerta; const _AlertaHistorialItem({required this.alerta}); @override @@ -303,13 +253,11 @@ class _AlertaHistorialItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Camión a ${alerta.distanciaTexto}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary), - ), + Text('Camión a ${alerta.distanciaTexto}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary)), const SizedBox(height: 2), Text(alerta.fechaFormateada, style: const TextStyle( @@ -324,7 +272,6 @@ class _AlertaHistorialItem extends StatelessWidget { } } -// ── Etiqueta de día ─────────────────────────────────────────────────────────── class _EtiquetaDia extends StatelessWidget { final String texto; const _EtiquetaDia({required this.texto}); @@ -350,32 +297,26 @@ class _EtiquetaDia extends StatelessWidget { } } -// ── Estado vacío ────────────────────────────────────────────────────────────── +// ── Sin alertas ─────────────────────────────────────────────────────────────── class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 60), + return const Padding( + padding: EdgeInsets.symmetric(vertical: 60), child: Column( children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - shape: BoxShape.circle, - ), - child: const Icon(Icons.notifications_outlined, - color: AppTheme.primary, size: 34), - ), - const SizedBox(height: 16), - const Text('Sin alertas por ahora', + Icon(Icons.notifications_outlined, + color: AppTheme.primary, size: 48), + SizedBox(height: 16), + Text('Sin alertas por ahora', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textPrimary)), - const SizedBox(height: 6), - const Text( + SizedBox(height: 6), + Text( 'Te notificaremos cuando el camión\nesté cerca de tu casa.', textAlign: TextAlign.center, style: TextStyle( diff --git a/recolecta_app/lib/features/auth/login_page.dart b/recolecta_app/lib/features/auth/login_page.dart index ffe09c4..603afb0 100644 --- a/recolecta_app/lib/features/auth/login_page.dart +++ b/recolecta_app/lib/features/auth/login_page.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:dio/dio.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; import '../../core/services/auth_controller.dart'; +import '../../core/models/auth_state.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -13,134 +17,226 @@ class LoginPage extends ConsumerStatefulWidget { class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - bool _obscurePassword = true; + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscurePass = true; + + @override + void initState() { + super.initState(); + ref.listenManual>(authControllerProvider, ( + prev, + next, + ) { + if (!mounted) return; + if (next is AsyncError) { + String errorMessage = 'Ocurrió un error inesperado'; + final error = next.error; + + if (error is DioException) { + if (error.response?.data != null && error.response?.data is Map) { + errorMessage = + error.response!.data['detail'] ?? 'Credenciales inválidas'; + } else { + errorMessage = 'Error de conexión con el servidor'; + } + } else { + errorMessage = error.toString(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: AppTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + }); + } @override void dispose() { - _emailController.dispose(); - _passwordController.dispose(); + _emailCtrl.dispose(); + _passCtrl.dispose(); super.dispose(); } Future _submit() async { - if (!(_formKey.currentState?.validate() ?? false)) { - return; - } - - try { - await ref - .read(authControllerProvider.notifier) - .login( - email: _emailController.text.trim(), - password: _passwordController.text, - ); - if (!mounted) { - return; - } - context.go('/home'); - } catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error.toString()))); - } + if (!(_formKey.currentState?.validate() ?? false)) return; + await ref + .read(authControllerProvider.notifier) + .login(email: _emailCtrl.text.trim(), password: _passCtrl.text); } @override Widget build(BuildContext context) { - final authState = ref.watch(authControllerProvider); - final loading = authState.isLoading; + final loading = ref.watch(authControllerProvider).isLoading; return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: const Text( + 'Iniciar sesión', + style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + ), body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + // ── Encabezado ────────────────────────────────────────── + Row( children: [ - const SizedBox(height: 12), - const Icon(Icons.delete_outline_rounded, size: 54), - const SizedBox(height: 16), - Text( - 'Recolecta', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - Text( - 'Accede para ver solo tu ruta asignada.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 28), - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Correo electrónico', - hintText: 'tu@correo.com', + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: AppTheme.primary, + size: 26, ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Ingresa tu correo' - : null, ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Contraseña', - hintText: '••••••••', - suffixIcon: IconButton( - onPressed: () => setState( - () => _obscurePassword = !_obscurePassword, - ), - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, + const SizedBox(width: 14), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recolecta', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, ), ), - ), - validator: (value) => (value == null || value.length < 6) - ? 'La contraseña debe tener al menos 6 caracteres' - : null, - ), - const SizedBox(height: 24), - SizedBox( - height: 52, - child: FilledButton( - onPressed: loading ? null : _submit, - child: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Text('Entrar'), - ), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/register'), - child: const Text('Crear cuenta'), + Text( + 'Bienvenido de nuevo', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ], ), ], ), - ), + + const SizedBox(height: 32), + + // ── Formulario ────────────────────────────────────────── + AppFormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Ingresa tu correo' + : null, + ), + const SizedBox(height: 16), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + validator: (v) => (v == null || v.length < 6) + ? 'Mínimo 6 caracteres' + : null, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => + setState(() => _obscurePass = !_obscurePass), + ), + ), + + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primary, + ), + child: const Text( + '¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13), + ), + ), + ), + + const SizedBox(height: 24), + + // ── Botón ─────────────────────────────────────────────── + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : _submit, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Ingresar', key: ValueKey('text')), + ), + ), + ), + + const SizedBox(height: 36), + + // ── Crear cuenta ──────────────────────────────────────── + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '¿No tienes cuenta? ', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + GestureDetector( + onTap: () => context.go('/register'), + child: const Text( + 'Regístrate', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary, + ), + ), + ), + ], + ), + ), + ], ), ), ), diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index 72ca14c..d168de8 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -1,8 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; import '../../core/services/auth_controller.dart'; +import '../../core/models/auth_state.dart'; +import '../addresses/colonias_selector.dart'; +import '../../core/models/colonia.dart'; +import '../home/colonias_data.dart'; class RegisterPage extends ConsumerStatefulWidget { const RegisterPage({super.key}); @@ -12,173 +22,632 @@ class RegisterPage extends ConsumerStatefulWidget { } class _RegisterPageState extends ConsumerState { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _phoneController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _obscurePassword = true; + final _pageController = PageController(); + int _currentPage = 0; + + final _step1FormKey = GlobalKey(); + // Paso 1 + final _emailCtrl = TextEditingController(); + final _telefonoCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscurePass = true; + + // Paso 2 + final _cpCtrl = TextEditingController(); + final _calleCtrl = TextEditingController(); + Colonia? _selectedColonia; + LatLng? _selectedLocation; + int _radioAlerta = 200; + + @override + void initState() { + super.initState(); + ref.listenManual>(authControllerProvider, ( + prev, + next, + ) { + if (!mounted) return; + if (next is AsyncError) { + String errorMessage = 'Ocurrió un error inesperado'; + final error = next.error; + + if (error is DioException) { + if (error.response?.data != null && error.response?.data is Map) { + errorMessage = + error.response!.data['detail'] ?? 'Error al registrarse'; + } else { + errorMessage = 'Error de conexión con el servidor'; + } + } else { + errorMessage = error.toString(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: AppTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + }); + } @override void dispose() { - _emailController.dispose(); - _phoneController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); + _pageController.dispose(); + _emailCtrl.dispose(); + _telefonoCtrl.dispose(); + _passCtrl.dispose(); + _calleCtrl.dispose(); + _cpCtrl.dispose(); super.dispose(); } - Future _submit() async { - if (!(_formKey.currentState?.validate() ?? false)) { + void _nextPage() { + if (!(_step1FormKey.currentState?.validate() ?? false)) return; + + _pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + setState(() => _currentPage = 1); + } + + Future _register() async { + if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ingresa tu calle y selecciona una colonia'), + behavior: SnackBarBehavior.floating, + ), + ); return; } + // 1. Registra al usuario + await ref + .read(authControllerProvider.notifier) + .register( + email: _emailCtrl.text.trim(), + phone: _telefonoCtrl.text.trim(), + password: _passCtrl.text, + ); + + // Detenernos si hubo algún error en el auth (ej. contraseña corta) + if (ref.read(authControllerProvider).hasError) return; + + // 2. Guardar la dirección en el backend de forma silenciosa try { - await ref - .read(authControllerProvider.notifier) - .register( - email: _emailController.text.trim(), - phone: _phoneController.text.trim(), - password: _passwordController.text, - ); - if (!mounted) { - return; + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'token') ?? ''; + + if (token.isNotEmpty) { + final dio = Dio( + BaseOptions( + baseUrl: const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:8000', + ), + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + await dio.post( + '/addresses', + data: { + 'label': 'Mi Casa', + 'calle': _calleCtrl.text.trim(), + 'colonia': _selectedColonia!.nombre, + }, + ); } - context.go('/home'); - } catch (error) { - if (!mounted) { - return; + } catch (e) { + debugPrint('Aviso: No se pudo guardar la dirección inicial: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Error al guardar tu dirección. Inténtalo más tarde.', + ), + backgroundColor: AppTheme.danger, + ), + ); } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error.toString()))); + return; // No navegar si falla el guardado de la dirección + } + + // 3. Navegar a inicio de manera limpia + if (mounted) { + context.go( + '/home', + ); // ¡Solución al GoException! Navega a la ruta correcta } } @override Widget build(BuildContext context) { - final authState = ref.watch(authControllerProvider); - final loading = authState.isLoading; + final loading = ref.watch(authControllerProvider).isLoading; return Scaffold( - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: Text( + _currentPage == 0 ? 'Crear cuenta' : 'Mi dirección', + style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(8), + child: _StepIndicator(current: _currentPage, total: 2), + ), + ), + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _Step1( + formKey: _step1FormKey, + emailCtrl: _emailCtrl, + telefonoCtrl: _telefonoCtrl, + passCtrl: _passCtrl, + obscurePass: _obscurePass, + onTogglePass: () => setState(() => _obscurePass = !_obscurePass), + onNext: _nextPage, + ), + _Step2( + cpCtrl: _cpCtrl, + calleCtrl: _calleCtrl, + selectedColonia: _selectedColonia, + selectedLocation: _selectedLocation, + radioAlerta: _radioAlerta, + loading: loading, + onColoniaChanged: (c) { + setState(() { + _selectedColonia = c; + if (c != null && kColoniasCoordinates.containsKey(c.nombre)) { + _selectedLocation = kColoniasCoordinates[c.nombre]; + } + }); + }, + onLocationChanged: (l) => setState(() => _selectedLocation = l), + onRadioChanged: (v) => setState(() => _radioAlerta = v), + onRegister: _register, + ), + ], + ), + ); + } +} + +// ── Indicador de pasos ──────────────────────────────────────────────────────── +class _StepIndicator extends StatelessWidget { + final int current; + final int total; + const _StepIndicator({required this.current, required this.total}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Row( + children: List.generate(total, (i) { + final active = i <= current; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), + height: 4, + decoration: BoxDecoration( + color: active ? AppTheme.primary : AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }), + ), + ); + } +} + +// ── Paso 1: Cuenta ──────────────────────────────────────────────────────────── +class _Step1 extends StatelessWidget { + final GlobalKey formKey; + final TextEditingController emailCtrl, telefonoCtrl, passCtrl; + final bool obscurePass; + final VoidCallback onTogglePass; + final VoidCallback onNext; + + const _Step1({ + required this.formKey, + required this.emailCtrl, + required this.telefonoCtrl, + required this.passCtrl, + required this.obscurePass, + required this.onTogglePass, + required this.onNext, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + AppFormCard( + icon: Icons.person_outline, + title: 'Información de cuenta', + child: Column( + children: [ + AppFormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: emailCtrl, + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) + return 'Ingresa tu correo'; + final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+'); + if (!emailRegex.hasMatch(v.trim())) + return 'Ingresa un correo válido'; + return null; + }, + ), + const SizedBox(height: 14), + AppFormField( + label: 'Teléfono', + hint: '+52 461 123 4567', + controller: telefonoCtrl, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 14), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: passCtrl, + obscureText: obscurePass, + validator: (v) { + if (v == null || v.isEmpty) + return 'Ingresa una contraseña'; + if (v.length < 6) return 'Mínimo 6 caracteres'; + return null; + }, + suffix: IconButton( + icon: Icon( + obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: onTogglePass, + ), + ), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: onNext, + child: const Row( + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 12), - const Icon(Icons.person_add_alt_1_outlined, size: 54), - const SizedBox(height: 16), - Text( - 'Crear cuenta', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - Text( - 'Registra tu correo, teléfono y contraseña para continuar.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 28), - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Correo electrónico', - hintText: 'tu@correo.com', - ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Ingresa tu correo' - : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _phoneController, - keyboardType: TextInputType.phone, - decoration: const InputDecoration( - labelText: 'Teléfono', - hintText: '+52 461 123 4567', - ), - validator: (value) => - (value == null || value.trim().isEmpty) - ? 'Ingresa tu teléfono' - : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Contraseña', - hintText: '••••••••', - suffixIcon: IconButton( - onPressed: () => setState( - () => _obscurePassword = !_obscurePassword, - ), - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - validator: (value) => (value == null || value.length < 6) - ? 'La contraseña debe tener al menos 6 caracteres' - : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: _obscurePassword, - decoration: const InputDecoration( - labelText: 'Confirmar contraseña', - hintText: '••••••••', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Confirma tu contraseña'; - } - if (value != _passwordController.text) { - return 'Las contraseñas no coinciden'; - } - return null; - }, - ), - const SizedBox(height: 24), - SizedBox( - height: 52, - child: FilledButton( - onPressed: loading ? null : _submit, - child: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Text('Registrarme'), - ), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/login'), - child: const Text('Ya tengo cuenta'), - ), + Text('Siguiente'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), ], ), ), ), - ), + const SizedBox(height: 20), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '¿Ya tienes cuenta? ', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + GestureDetector( + onTap: () => context.go('/login'), + child: const Text( + 'Inicia sesión', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Paso 2: Dirección ───────────────────────────────────────────────────────── +class _Step2 extends StatelessWidget { + final TextEditingController cpCtrl; + final TextEditingController calleCtrl; + final Colonia? selectedColonia; + final LatLng? selectedLocation; + final int radioAlerta; + final bool loading; + final ValueChanged onColoniaChanged; + final ValueChanged onLocationChanged; + final ValueChanged onRadioChanged; + final VoidCallback onRegister; + + const _Step2({ + required this.cpCtrl, + required this.calleCtrl, + required this.selectedColonia, + required this.selectedLocation, + required this.radioAlerta, + required this.loading, + required this.onColoniaChanged, + required this.onLocationChanged, + required this.onRadioChanged, + required this.onRegister, + }); + + @override + Widget build(BuildContext context) { + final mapCenter = selectedLocation ?? const LatLng(20.5222, -100.8123); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + AppFormCard( + icon: Icons.home_outlined, + title: 'Dirección de tu casa', + child: Column( + children: [ + AppFormField( + label: 'Código Postal', + hint: 'Ej. 38000', + controller: cpCtrl, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 14), + ColoniasSelector( + labelText: 'Colonia', + initialValue: selectedColonia, + onChanged: onColoniaChanged, + ), + const SizedBox(height: 14), + AppFormField( + label: 'Calle y número', + hint: 'Av. Insurgentes 245', + controller: calleCtrl, + ), + const SizedBox(height: 16), + const Text( + 'Toca el mapa para ubicar tu casa exacta:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.border), + ), + clipBehavior: Clip.hardEdge, + child: FlutterMap( + key: ValueKey(selectedColonia?.nombre ?? 'default'), + options: MapOptions( + initialCenter: mapCenter, + initialZoom: 15.0, + onTap: (_, latlng) => onLocationChanged(latlng), + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + if (selectedLocation != null) + MarkerLayer( + markers: [ + Marker( + point: selectedLocation!, + width: 40, + height: 40, + child: const Icon( + Icons.location_on, + color: AppTheme.danger, + size: 40, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + AppFormCard( + icon: Icons.notifications_outlined, + title: 'Distancia de alerta', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Te avisamos cuando el camión esté a esta distancia de tu casa.', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + ...[200, 400, 600].map( + (dist) => _RadioOption( + value: dist, + groupValue: radioAlerta, + label: '$dist metros', + sublabel: dist == 200 + ? '~2-3 min de anticipación' + : dist == 400 + ? '~4-5 min de anticipación' + : '~6-8 min de anticipación', + onChanged: onRadioChanged, + ), + ), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : onRegister, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + key: ValueKey('text'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Registrarme'), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + const Center( + child: Text( + 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} + +// ── Opción radio ────────────────────────────────────────────────────────────── +class _RadioOption extends StatelessWidget { + final int value, groupValue; + final String label, sublabel; + final ValueChanged onChanged; + + const _RadioOption({ + required this.value, + required this.groupValue, + required this.label, + required this.sublabel, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + return GestureDetector( + onTap: () => onChanged(value), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: selected ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: selected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: 2, + ), + ), + child: selected + ? Center( + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primary, + ), + ), + ) + : null, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selected + ? AppTheme.primaryDark + : AppTheme.textPrimary, + ), + ), + Text( + sublabel, + style: TextStyle( + fontSize: 11, + color: selected ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ], + ), + ], ), ), ); diff --git a/recolecta_app/lib/features/driver/driver_shell.dart b/recolecta_app/lib/features/driver/driver_shell.dart new file mode 100644 index 0000000..11b421a --- /dev/null +++ b/recolecta_app/lib/features/driver/driver_shell.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class DriverShell extends StatefulWidget { + const DriverShell({super.key, required this.child}); + final Widget child; + + @override + State createState() => _DriverShellState(); +} + +class _DriverShellState extends State { + int _currentIndex = 0; + + void _onTap(int index) { + setState(() { + _currentIndex = index; + }); + switch (index) { + case 0: + context.go('/driver'); + break; + case 1: + context.go('/driver/collections'); + break; + case 2: + context.go('/driver/incident'); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: _onTap, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.route), label: 'Mi Ruta'), + BottomNavigationBarItem( + icon: Icon(Icons.location_on), + label: 'Recolecciones', + ), + BottomNavigationBarItem( + icon: Icon(Icons.warning), + label: 'Reportar Falla', + ), + ], + ), + ); + } +} diff --git a/recolecta_app/lib/features/driver/screens/driver_collections_screen.dart b/recolecta_app/lib/features/driver/screens/driver_collections_screen.dart new file mode 100644 index 0000000..0141cdd --- /dev/null +++ b/recolecta_app/lib/features/driver/screens/driver_collections_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class DriverCollectionsScreen extends StatelessWidget { + const DriverCollectionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Recolecciones')), + body: const Center( + child: Text( + 'TODO: Driver Collections Screen - Lista de domicilios para marcar recolección', + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/driver/screens/driver_home_screen.dart b/recolecta_app/lib/features/driver/screens/driver_home_screen.dart new file mode 100644 index 0000000..7debec1 --- /dev/null +++ b/recolecta_app/lib/features/driver/screens/driver_home_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class DriverHomeScreen extends StatelessWidget { + const DriverHomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Mi Ruta')), + body: const Center( + child: Text( + 'TODO: Driver Home Screen - Mostrar nombre de ruta, turno y estado', + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/driver/screens/driver_incident_screen.dart b/recolecta_app/lib/features/driver/screens/driver_incident_screen.dart new file mode 100644 index 0000000..f18ff0d --- /dev/null +++ b/recolecta_app/lib/features/driver/screens/driver_incident_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class DriverIncidentScreen extends StatelessWidget { + const DriverIncidentScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Reportar Incidente')), + body: const Center( + child: Text( + 'TODO: Driver Incident Screen - Formulario para reportar falla mecánica', + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart new file mode 100644 index 0000000..f8faace --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -0,0 +1,489 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; +import '../../core/network/api_client.dart'; + +// ── Provider de ETA ─────────────────────────────────────────────────────────── +final etaProvider = FutureProvider.autoDispose<_EtaResult>((ref) async { + final dio = ref.read(apiClientProvider); + + final addressesResp = await dio.get('/addresses'); + final raw = addressesResp.data; + + List items = const []; + if (raw is List) { + items = raw; + } else if (raw is Map && raw['data'] is List) { + items = raw['data'] as List; + } else if (raw is Map && raw['addresses'] is List) { + items = raw['addresses'] as List; + } + + if (items.isEmpty) { + return const _EtaResult.noAddress(); + } + + final addressId = items.first['id'] as String; + final etaResp = await dio.get( + '/eta', + queryParameters: {'address_id': addressId}, + ); + + final data = etaResp.data as Map; + return _EtaResult( + mensaje: data['mensaje'] as String? ?? '', + status: data['status'] as String? ?? '', + direccion: items.first['calle'] as String? ?? '', + colonia: items.first['colonia'] as String? ?? '', + hasAddress: true, + ); +}); + +class _EtaResult { + final String mensaje; + final String status; + final String direccion; + final String colonia; + final bool hasAddress; + + const _EtaResult({ + required this.mensaje, + required this.status, + required this.direccion, + required this.colonia, + required this.hasAddress, + }); + + const _EtaResult.noAddress() + : mensaje = '', + status = '', + direccion = '', + colonia = '', + hasAddress = false; + + double get progreso { + if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) { + return 0.85; + } + if (mensaje.contains('finalizado')) return 1.0; + return 0.35; + } + + String get etiquetaEstado { + if (status == 'completada') return 'Finalizado'; + if (status == 'en_ruta') return 'En ruta'; + return 'Pendiente'; + } +} + +// ── Pantalla ETA ────────────────────────────────────────────────────────────── +class EtaScreen extends ConsumerWidget { + const EtaScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final etaAsync = ref.watch(etaProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Estado del camión'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Actualizar', + onPressed: () => ref.invalidate(etaProvider), + ), + ], + ), + body: etaAsync.when( + loading: () => const _EtaLoading(), + error: (error, _) => _EtaError( + error: error.toString(), + onRetry: () => ref.invalidate(etaProvider), + ), + data: (result) => result.hasAddress + ? _EtaContent(result: result) + : _NoAddressState( + onAdd: () => context.go('/addresses/new'), + ), + ), + ); + } +} + +// ── Contenido ETA ───────────────────────────────────────────────────────────── +class _EtaContent extends StatelessWidget { + final _EtaResult result; + const _EtaContent({required this.result}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Tarjeta de estado principal ──────────────────────────────── + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.primaryMid), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Camión recolector', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark, + ), + ), + const SizedBox(height: 2), + AppStatusBadge.green(result.etiquetaEstado), + ], + ), + ), + _LiveDot(active: result.status == 'en_ruta'), + ], + ), + + const SizedBox(height: 20), + + // Mensaje ETA + Text( + result.mensaje, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark, + height: 1.3, + ), + ), + + const SizedBox(height: 16), + + // Barra de progreso + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: result.progreso, + backgroundColor: + AppTheme.primaryMid.withValues(alpha: 0.35), + valueColor: + const AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 8, + ), + ), + const SizedBox(height: 6), + const Row( + children: [ + Text('Inicio de ruta', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + Spacer(), + Text('Tu casa', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + ], + ), + ], + ), + ), + + const SizedBox(height: 16), + + // ── Domicilio registrado ─────────────────────────────────────── + AppInfoRow( + icon: Icons.home_outlined, + label: 'Col. ${result.colonia}', + value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, + trailing: AppStatusBadge.green('Activo'), + ), + + const SizedBox(height: 16), + + // ── Aviso de privacidad ──────────────────────────────────────── + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.blueLight, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.shield_outlined, color: AppTheme.blue, size: 18), + SizedBox(width: 10), + Expanded( + child: Text( + 'Tu ubicación exacta y la del camión no se comparten. Solo ves el estado de tu ruta.', + style: TextStyle( + fontSize: 12, color: AppTheme.blue, height: 1.5), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // ── Horario estimado de la semana ────────────────────────────── + AppSectionTitle(title: 'Horario del camión'), + _HorarioCard(), + ], + ); + } +} + +// ── Punto animado "en vivo" ─────────────────────────────────────────────────── +class _LiveDot extends StatefulWidget { + final bool active; + const _LiveDot({required this.active}); + + @override + State<_LiveDot> createState() => _LiveDotState(); +} + +class _LiveDotState extends State<_LiveDot> + with SingleTickerProviderStateMixin { + late final AnimationController _anim; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.active) { + return const SizedBox.shrink(); + } + return AnimatedBuilder( + animation: _anim, + builder: (_, child) => Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primary + .withValues(alpha: 0.5 + _anim.value * 0.5), + ), + ), + ); + } +} + +// ── Horario ─────────────────────────────────────────────────────────────────── +class _HorarioCard extends StatelessWidget { + final List<_HorarioDia> _dias = const [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles',hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + ]; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + children: _dias.map((d) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Text( + d.dia, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: d.activo + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + const Spacer(), + Text( + d.hora, + style: TextStyle( + fontSize: 13, + color: d.activo ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } +} + +class _HorarioDia { + final String dia; + final String hora; + final bool activo; + const _HorarioDia( + {required this.dia, required this.hora, required this.activo}); +} + +// ── Sin domicilio ───────────────────────────────────────────────────────────── +class _NoAddressState extends StatelessWidget { + final VoidCallback onAdd; + const _NoAddressState({required this.onAdd}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + ), + child: const Icon(Icons.home_outlined, + color: AppTheme.primary, size: 40), + ), + const SizedBox(height: 20), + const Text( + 'Sin domicilio registrado', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary), + ), + const SizedBox(height: 8), + const Text( + 'Registra tu domicilio para\nrecibir el ETA de tu ruta.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary, height: 1.5), + ), + const SizedBox(height: 24), + SizedBox( + width: 200, + child: ElevatedButton( + onPressed: onAdd, + child: const Text('Agregar domicilio'), + ), + ), + ], + ), + ), + ); + } +} + +// ── Cargando ────────────────────────────────────────────────────────────────── +class _EtaLoading extends StatelessWidget { + const _EtaLoading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppTheme.primary), + SizedBox(height: 16), + Text('Consultando estado del camión…', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)), + ], + ), + ); + } +} + +// ── Error ───────────────────────────────────────────────────────────────────── +class _EtaError extends StatelessWidget { + final String error; + final VoidCallback onRetry; + const _EtaError({required this.error, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.wifi_off_outlined, + color: AppTheme.textSecondary, size: 48), + const SizedBox(height: 16), + const Text('No se pudo obtener el estado', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 8), + Text(error, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 20), + SizedBox( + width: 160, + child: ElevatedButton( + onPressed: onRetry, + child: const Text('Reintentar'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/feedback/feedback_screen.dart b/recolecta_app/lib/features/feedback/feedback_screen.dart new file mode 100644 index 0000000..be78862 --- /dev/null +++ b/recolecta_app/lib/features/feedback/feedback_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class FeedbackScreen extends StatelessWidget { + const FeedbackScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Retroalimentación')), + body: const Center( + child: Text( + 'TODO: Feedback Screen - Formulario de queja hacia la unidad', + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/home/citizen_home_screen.dart b/recolecta_app/lib/features/home/citizen_home_screen.dart new file mode 100644 index 0000000..e0ed58e --- /dev/null +++ b/recolecta_app/lib/features/home/citizen_home_screen.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class CitizenHomeScreen extends StatelessWidget { + const CitizenHomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Inicio')), + body: const Center( + child: Text('TODO: Citizen Home Screen - Mostrar tarjeta ETA'), + ), + ); + } +} diff --git a/recolecta_app/lib/features/home/citizen_shell.dart b/recolecta_app/lib/features/home/citizen_shell.dart new file mode 100644 index 0000000..f4a8048 --- /dev/null +++ b/recolecta_app/lib/features/home/citizen_shell.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class CitizenShell extends StatefulWidget { + const CitizenShell({super.key, required this.child}); + final Widget child; + + @override + State createState() => _CitizenShellState(); +} + +class _CitizenShellState extends State { + int _currentIndex = 0; + + void _onTap(int index) { + setState(() { + _currentIndex = index; + }); + switch (index) { + case 0: + context.go('/home'); + break; + case 1: + context.go('/guide'); + break; + case 2: + context.go('/feedback'); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: _onTap, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Inicio'), + BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Guía'), + BottomNavigationBarItem( + icon: Icon(Icons.feedback), + label: 'Retroalimentación', + ), + ], + ), + ); + } +} diff --git a/recolecta_app/lib/features/home/colonias_data.dart b/recolecta_app/lib/features/home/colonias_data.dart new file mode 100644 index 0000000..d22cb0c --- /dev/null +++ b/recolecta_app/lib/features/home/colonias_data.dart @@ -0,0 +1,14 @@ +import 'package:latlong2/latlong.dart'; + +// Coordenadas de referencia para el centro de cada colonia en Celaya, Gto. +// Para el MVP, estas coordenadas son fijas y coinciden con el JSON de `colonias-rutas`. +// En una versión futura, podrían venir de una API de geocodificación o de la BD. +const Map kColoniasCoordinates = { + 'Zona Centro': LatLng(20.52254, -100.81153), + 'Las Arboledas': LatLng(20.51422, -100.82793), + 'San Juanico': LatLng(20.54066, -100.83831), + 'Los Olivos': LatLng(20.54621, -100.77274), + 'Rancho Seco': LatLng(20.49110, -100.81080), + 'Las Insurgentes': LatLng(20.52427, -100.79548), + 'Trojes': LatLng(20.50899, -100.77167), +}; diff --git a/viewsv1/views/lib/screens/house_screen.dart b/recolecta_app/lib/features/home/house_screen.dart similarity index 58% rename from viewsv1/views/lib/screens/house_screen.dart rename to recolecta_app/lib/features/home/house_screen.dart index 8410fc4..d79a304 100644 --- a/viewsv1/views/lib/screens/house_screen.dart +++ b/recolecta_app/lib/features/home/house_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../models/models.dart'; -import '../widgets/widgets.dart' as w; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/models/ui_models.dart'; +import 'colonias_data.dart'; +import '../../core/widgets/app_widgets.dart'; class MyHouseScreen extends StatefulWidget { const MyHouseScreen({super.key}); @@ -11,21 +16,68 @@ class MyHouseScreen extends StatefulWidget { } class _MyHouseScreenState extends State { - HouseModel _casa = const HouseModel( - id: 'casa-01', - calle: 'Av. Insurgentes 245', - colonia: 'Centro', - codigoPostal: '38000', - latitud: 20.5226, - longitud: -100.8191, - radioAlertaMetros: 200, - alertaCercana: true, - alertaMedia: false, - recordatorioDiario: true, - ); + bool _isLoading = true; + UIHouseModel? _casa; + + @override + void initState() { + super.initState(); + _cargarDomicilio(); + } + + Future _cargarDomicilio() async { + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'token') ?? ''; + + if (token.isEmpty) { + setState(() => _isLoading = false); + return; + } + + final dio = Dio( + BaseOptions( + baseUrl: const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:8000', + ), + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + final res = await dio.get('/addresses'); + if (res.data is List && (res.data as List).isNotEmpty) { + final addr = res.data[0]; + setState(() { + _casa = UIHouseModel.fromJson(addr); + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + setState(() => _isLoading = false); + debugPrint('Error al cargar domicilio: $e'); + } + } @override Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + backgroundColor: AppTheme.background, + body: Center(child: CircularProgressIndicator()), + ); + } + + if (_casa == null) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Mi casa')), + body: const Center(child: Text('No tienes un domicilio registrado.')), + ); + } + return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( @@ -41,72 +93,69 @@ class _MyHouseScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ - // ── Tarjeta de la casa ────────────────────────────────────── - _CasaCard(casa: _casa), - + _CasaCard(casa: _casa!), const SizedBox(height: 16), - - // ── Configuración de radio ────────────────────────────────── - w.SectionTitle(title: 'Radio de alerta'), + const AppSectionTitle(title: 'Mapa del Sector (Restringido)'), + _MapaColoniaRestringido(colonia: _casa!.colonia), + const SizedBox(height: 16), + const AppSectionTitle(title: 'Radio de alerta'), _RadioAlertaCard( - radioActual: _casa.radioAlertaMetros, - onChanged: (v) => setState(() { - _casa = _casa.copyWith(radioAlertaMetros: v); - }), + radioActual: _casa!.radioAlertaMetros, + onChanged: (v) => + setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)), ), - const SizedBox(height: 16), - - // ── Notificaciones ────────────────────────────────────────── - w.SectionTitle(title: 'Notificaciones'), + const AppSectionTitle(title: 'Notificaciones'), _NotificacionesCard( - casa: _casa, + casa: _casa!, onAlertaCercanaChanged: (v) => - setState(() => _casa = _casa.copyWith(alertaCercana: v)), + setState(() => _casa = _casa!.copyWith(alertaCercana: v)), onAlertaMediaChanged: (v) => - setState(() => _casa = _casa.copyWith(alertaMedia: v)), + setState(() => _casa = _casa!.copyWith(alertaMedia: v)), onRecordatorioChanged: (v) => - setState(() => _casa = _casa.copyWith(recordatorioDiario: v)), + setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)), ), - const SizedBox(height: 16), - - // ── Horario estimado ──────────────────────────────────────── - w.SectionTitle(title: 'Horario del camión'), + const AppSectionTitle(title: 'Horario del camión'), _HorarioCard(), - const SizedBox(height: 16), - - // ── Agregar otra casa ─────────────────────────────────────── GestureDetector( - onTap: () => _mostrarAgregarCasa(context), + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Funcionalidad próximamente disponible'), + behavior: SnackBarBehavior.floating, + backgroundColor: AppTheme.primary, + ), + ), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all( - color: AppTheme.primaryMid, - width: 1, - style: BorderStyle.solid), + border: Border.all(color: AppTheme.primaryMid), boxShadow: AppTheme.softShadow, ), - child: Row( + child: const Row( mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.add_home_outlined, - color: AppTheme.primary, size: 20), + children: [ + Icon( + Icons.add_home_outlined, + color: AppTheme.primary, + size: 20, + ), SizedBox(width: 8), - Text('Agregar otra dirección', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.primary)), + Text( + 'Agregar otra dirección', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.primary, + ), + ), ], ), ), ), - const SizedBox(height: 24), ], ), @@ -120,26 +169,17 @@ class _MyHouseScreenState extends State { backgroundColor: AppTheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( - top: Radius.circular(AppTheme.radiusXl)), - ), - builder: (_) => _EditarDireccionSheet(casa: _casa), - ); - } - - void _mostrarAgregarCasa(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Funcionalidad próximamente disponible'), - behavior: SnackBarBehavior.floating, - backgroundColor: AppTheme.primary, + top: Radius.circular(AppTheme.radiusXl), + ), ), + builder: (_) => _EditarDireccionSheet(casa: _casa!), ); } } -// ── Tarjeta principal de la casa ────────────────────────────────────────────── +// ── Tarjeta de la casa ──────────────────────────────────────────────────────── class _CasaCard extends StatelessWidget { - final HouseModel casa; + final UIHouseModel casa; const _CasaCard({required this.casa}); @override @@ -155,7 +195,6 @@ class _CasaCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Row( children: [ Container( @@ -165,49 +204,40 @@ class _CasaCard extends StatelessWidget { color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.home_outlined, - color: AppTheme.primary, size: 24), + child: const Icon( + Icons.home_outlined, + color: AppTheme.primary, + size: 24, + ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(casa.alias, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 2), - w.StatusBadge.green( - casa.activa ? 'Activa' : 'Inactiva'), + Text( + casa.alias, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'), ], ), ), - IconButton( - icon: const Icon(Icons.more_vert, - color: AppTheme.textSecondary, size: 20), - onPressed: () {}, - ), ], ), - const SizedBox(height: 14), const Divider(color: AppTheme.borderLight), const SizedBox(height: 10), - - // Detalles _DetailRow( icon: Icons.location_on_outlined, text: casa.direccionCompleta, ), const SizedBox(height: 8), - _DetailRow( - icon: Icons.my_location_outlined, - text: - '${casa.latitud.toStringAsFixed(4)}, ${casa.longitud.toStringAsFixed(4)}', - ), - const SizedBox(height: 8), _DetailRow( icon: Icons.radar_outlined, text: 'Alerta a ${casa.radioAlertaMetros} m de distancia', @@ -218,6 +248,62 @@ class _CasaCard extends StatelessWidget { } } +// ── Mapa de Colonia (Restringido para Privacidad) ────────────────────────────── +class _MapaColoniaRestringido extends StatelessWidget { + final String colonia; + const _MapaColoniaRestringido({required this.colonia}); + + @override + Widget build(BuildContext context) { + // Usa las coordenadas del archivo centralizado de datos de colonias. + final center = + kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123); + + // Creamos una "caja" o límite geográfico de aprox 1km a la redonda + final bounds = LatLngBounds( + LatLng(center.latitude - 0.01, center.longitude - 0.01), + LatLng(center.latitude + 0.01, center.longitude + 0.01), + ); + + return Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 1), + ), + clipBehavior: Clip.hardEdge, + child: FlutterMap( + options: MapOptions( + initialCameraFit: CameraFit.bounds(bounds: bounds), + // ESTO ES LA MAGIA DE LA PRIVACIDAD: Bloquea el mapa a esta caja + cameraConstraint: CameraConstraint.contain(bounds: bounds), + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + CircleLayer( + circles: [ + CircleMarker( + point: center, + color: AppTheme.primary.withValues(alpha: 0.2), + borderColor: AppTheme.primary, + borderStrokeWidth: 2, + radius: 350, // 350 metros a la redonda remarcados + useRadiusInMeter: true, + ), + ], + ), + ], + ), + ); + } +} + class _DetailRow extends StatelessWidget { final IconData icon; final String text; @@ -231,9 +317,14 @@ class _DetailRow extends StatelessWidget { Icon(icon, size: 15, color: AppTheme.textSecondary), const SizedBox(width: 8), Expanded( - child: Text(text, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary, height: 1.4)), + child: Text( + text, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.4, + ), + ), ), ], ); @@ -263,8 +354,7 @@ class _RadioAlertaCard extends StatelessWidget { onTap: () => onChanged(dist), child: Container( margin: const EdgeInsets.only(bottom: 8), - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), decoration: BoxDecoration( color: selected ? AppTheme.primaryLight : AppTheme.background, borderRadius: BorderRadius.circular(AppTheme.radiusSm), @@ -300,12 +390,13 @@ class _RadioAlertaCard extends StatelessWidget { dist == 200 ? '~2-3 min' : dist == 400 - ? '~4-5 min' - : '~6-8 min', + ? '~4-5 min' + : '~6-8 min', style: const TextStyle( - fontSize: 12, - color: AppTheme.primary, - fontWeight: FontWeight.w500), + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -319,7 +410,7 @@ class _RadioAlertaCard extends StatelessWidget { // ── Notificaciones ──────────────────────────────────────────────────────────── class _NotificacionesCard extends StatelessWidget { - final HouseModel casa; + final UIHouseModel casa; final ValueChanged onAlertaCercanaChanged; final ValueChanged onAlertaMediaChanged; final ValueChanged onRecordatorioChanged; @@ -343,19 +434,19 @@ class _NotificacionesCard extends StatelessWidget { ), child: Column( children: [ - w.LabeledSwitch( + AppLabeledSwitch( label: 'Alerta cuando el camión esté cerca', value: casa.alertaCercana, onChanged: onAlertaCercanaChanged, ), const Divider(height: 1, color: AppTheme.borderLight), - w.LabeledSwitch( + AppLabeledSwitch( label: 'Alerta a distancia media', value: casa.alertaMedia, onChanged: onAlertaMediaChanged, ), const Divider(height: 1, color: AppTheme.borderLight), - w.LabeledSwitch( + AppLabeledSwitch( label: 'Recordatorio diario del horario', value: casa.recordatorioDiario, onChanged: onRecordatorioChanged, @@ -366,7 +457,7 @@ class _NotificacionesCard extends StatelessWidget { } } -// ── Horario del camión ──────────────────────────────────────────────────────── +// ── Horario ─────────────────────────────────────────────────────────────────── class _HorarioCard extends StatelessWidget { final List<_HorarioDia> _dias = const [ _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), @@ -394,20 +485,24 @@ class _HorarioCard extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 7), child: Row( children: [ - Text(d.dia, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: d.activo - ? AppTheme.textPrimary - : AppTheme.textSecondary)), + Text( + d.dia, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: d.activo + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), const Spacer(), - Text(d.hora, - style: TextStyle( - fontSize: 13, - color: d.activo - ? AppTheme.primary - : AppTheme.textSecondary)), + Text( + d.hora, + style: TextStyle( + fontSize: 13, + color: d.activo ? AppTheme.primary : AppTheme.textSecondary, + ), + ), ], ), ); @@ -421,30 +516,35 @@ class _HorarioDia { final String dia; final String hora; final bool activo; - const _HorarioDia( - {required this.dia, required this.hora, required this.activo}); + const _HorarioDia({ + required this.dia, + required this.hora, + required this.activo, + }); } -// ── Sheet de editar dirección ───────────────────────────────────────────────── +// ── Sheet editar dirección ──────────────────────────────────────────────────── class _EditarDireccionSheet extends StatelessWidget { - final HouseModel casa; + final UIHouseModel casa; const _EditarDireccionSheet({required this.casa}); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( - left: 24, right: 24, top: 24, + left: 24, + right: 24, + top: 24, bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Handle Center( child: Container( - width: 36, height: 4, + width: 36, + height: 4, decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(4), @@ -452,34 +552,19 @@ class _EditarDireccionSheet extends StatelessWidget { ), ), const SizedBox(height: 20), - - const Text('Editar dirección', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 20), - - w.FormField( - label: 'Calle y número', initialValue: casa.calle), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - flex: 3, - child: w.FormField( - label: 'Colonia', initialValue: casa.colonia), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: w.FormField( - label: 'C.P.', initialValue: casa.codigoPostal), - ), - ], + const Text( + 'Editar dirección', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), ), + const SizedBox(height: 20), + AppFormField(label: 'Calle y número', initialValue: casa.calle), + const SizedBox(height: 14), + AppFormField(label: 'Colonia', initialValue: casa.colonia), const SizedBox(height: 24), - SizedBox( width: double.infinity, height: 50, diff --git a/viewsv1/views/lib/screens/main_shell.dart b/recolecta_app/lib/features/home/main_shell.dart similarity index 62% rename from viewsv1/views/lib/screens/main_shell.dart rename to recolecta_app/lib/features/home/main_shell.dart index cb9eb8d..e8c9631 100644 --- a/viewsv1/views/lib/screens/main_shell.dart +++ b/recolecta_app/lib/features/home/main_shell.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import '../widgets/widgets.dart' as w; -import 'map_screen.dart'; -import 'alerts_screen.dart'; +import '../../core/widgets/app_widgets.dart'; +import '../eta/eta_screen.dart'; +import '../alerts/alerts_screen.dart'; import 'house_screen.dart'; -import 'profile_screen.dart'; +import '../profile/profile_screen.dart'; class MainShell extends StatefulWidget { const MainShell({super.key}); @@ -15,8 +15,8 @@ class MainShell extends StatefulWidget { class _MainShellState extends State { int _currentIndex = 0; - final List _screens = const [ - MapScreen(), + static const List _screens = [ + EtaScreen(), AlertsScreen(), MyHouseScreen(), ProfileScreen(), @@ -25,11 +25,8 @@ class _MainShellState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: IndexedStack( - index: _currentIndex, - children: _screens, - ), - bottomNavigationBar: w.AppBottomNav( + body: IndexedStack(index: _currentIndex, children: _screens), + bottomNavigationBar: AppBottomNav( currentIndex: _currentIndex, onTap: (i) => setState(() => _currentIndex = i), ), diff --git a/recolecta_app/lib/features/profile/profile_screen.dart b/recolecta_app/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..ea944e6 --- /dev/null +++ b/recolecta_app/lib/features/profile/profile_screen.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; +import '../../core/services/auth_controller.dart'; +import '../../core/storage/secure_storage.dart'; +import '../../core/constants/auth_constants.dart'; + +class ProfileScreen extends ConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authControllerProvider).asData?.value; + final storage = ref.read(secureStorageProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Mi perfil')), + body: FutureBuilder<_ProfileData>( + future: _loadProfile(storage), + builder: (context, snapshot) { + final profile = snapshot.data ?? + _ProfileData( + email: authState?.token != null ? '…' : '', + role: authState?.userRole ?? 'citizen', + ); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _ProfileHeader(profile: profile), + const SizedBox(height: 20), + + const AppSectionTitle(title: 'Mi cuenta'), + AppMenuTile( + icon: Icons.person_outline, + title: 'Editar perfil', + subtitle: profile.email, + onTap: () {}, + ), + AppMenuTile( + icon: Icons.lock_outline, + title: 'Cambiar contraseña', + onTap: () {}, + ), + AppMenuTile( + icon: Icons.email_outlined, + title: 'Correo', + subtitle: profile.email, + onTap: () {}, + ), + + const SizedBox(height: 16), + const AppSectionTitle(title: 'Configuración'), + AppMenuTile( + icon: Icons.calendar_month_outlined, + title: 'Horario del camión', + subtitle: 'Mi ruta asignada', + onTap: () {}, + ), + AppMenuTile( + icon: Icons.notifications_outlined, + title: 'Notificaciones', + subtitle: 'Gestiona tus alertas', + onTap: () {}, + ), + if (profile.role == 'admin') + AppMenuTile( + icon: Icons.admin_panel_settings_outlined, + title: 'Panel de administración', + subtitle: 'Gestiona usuarios, rutas y camiones', + onTap: () => context.go('/admin'), + ), + + const SizedBox(height: 16), + const AppSectionTitle(title: 'Soporte'), + AppMenuTile( + icon: Icons.help_outline, + title: 'Ayuda y preguntas frecuentes', + onTap: () {}, + ), + AppMenuTile( + icon: Icons.bug_report_outlined, + title: 'Reportar un problema', + onTap: () {}, + ), + AppMenuTile( + icon: Icons.info_outline, + title: 'Acerca de la app', + subtitle: 'Versión 1.0.0', + onTap: () {}, + ), + + const SizedBox(height: 16), + AppMenuTile( + icon: Icons.logout_rounded, + title: 'Cerrar sesión', + iconColor: AppTheme.danger, + titleColor: AppTheme.danger, + trailing: const SizedBox.shrink(), + onTap: () => _confirmarCerrarSesion(context, ref), + ), + + const SizedBox(height: 32), + const Center( + child: Text( + 'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, color: AppTheme.textHint, height: 1.6), + ), + ), + const SizedBox(height: 24), + ], + ); + }, + ), + ); + } + + Future<_ProfileData> _loadProfile(dynamic storage) async { + final role = + await storage.read(key: authUserRoleStorageKey) as String? ?? 'citizen'; + return _ProfileData(role: role); + } + + void _confirmarCerrarSesion(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Cerrar sesión', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + content: const Text('¿Estás seguro de que deseas cerrar sesión?', + style: TextStyle(fontSize: 14, color: AppTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + await ref.read(authControllerProvider.notifier).logout(); + if (context.mounted) context.go('/login'); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Cerrar sesión', + style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +// ── Datos de perfil ─────────────────────────────────────────────────────────── +class _ProfileData { + final String email; + final String role; + + const _ProfileData({ + this.email = '', + this.role = 'citizen', + }); + + String get iniciales => + email.isNotEmpty ? email[0].toUpperCase() : 'U'; + + String get displayName => email; + bool get isAdmin => role == 'admin'; +} + +// ── Encabezado ──────────────────────────────────────────────────────────────── +class _ProfileHeader extends StatelessWidget { + final _ProfileData profile; + const _ProfileHeader({required this.profile}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + border: Border.all(color: AppTheme.primaryMid, width: 1.5), + ), + child: Center( + child: Text( + profile.iniciales, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(profile.displayName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + const SizedBox(height: 2), + Text(profile.email, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 6), + AppStatusBadge.green( + profile.isAdmin ? 'Administrador' : 'Ciudadano'), + ], + ), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/lib/features/separation_guide/models/separation_guide_model.dart b/recolecta_app/lib/features/separation_guide/models/separation_guide_model.dart new file mode 100644 index 0000000..548c8fd --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/models/separation_guide_model.dart @@ -0,0 +1,87 @@ +// Esta guía funciona offline. El chat con IA (mascota) es la capa extra cuando hay conexión — ver features/mascota/ +import 'package:flutter/material.dart'; + +class SeparationGuide { + final String version; + final List categorias; + + SeparationGuide({required this.version, required this.categorias}); + + factory SeparationGuide.fromJson(Map json) { + return SeparationGuide( + version: json['version'], + categorias: (json['categorias'] as List) + .map((category) => Category.fromJson(category)) + .toList(), + ); + } +} + +class Category { + final String id; + final String nombre; + final String color; + final String icono; + final String descripcion; + final List ejemplos; + final String consejo; + + Category({ + required this.id, + required this.nombre, + required this.color, + required this.icono, + required this.descripcion, + required this.ejemplos, + required this.consejo, + }); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'], + nombre: json['nombre'], + color: json['color'], + icono: json['icono'], + descripcion: json['descripcion'], + ejemplos: (json['ejemplos'] as List) + .map((example) => Example.fromJson(example)) + .toList(), + consejo: json['consejo'], + ); + } + + IconData get iconData { + switch (icono) { + case 'eco': + return Icons.eco; + case 'recycling': + return Icons.recycling; + case 'delete': + return Icons.delete; + case 'warning': + return Icons.warning; + default: + return Icons.help; + } + } + + Color get colorValue { + return Color(int.parse(color.substring(1, 7), radix: 16) + 0xFF000000); + } +} + +class Example { + final String nombre; + final bool acepta; + final String? razon; + + Example({required this.nombre, required this.acepta, this.razon}); + + factory Example.fromJson(Map json) { + return Example( + nombre: json['nombre'], + acepta: json['acepta'], + razon: json['razon'], + ); + } +} diff --git a/recolecta_app/lib/features/separation_guide/providers/separation_guide_provider.dart b/recolecta_app/lib/features/separation_guide/providers/separation_guide_provider.dart new file mode 100644 index 0000000..a2eff91 --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/providers/separation_guide_provider.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart'; + +// Esta guía funciona offline. El chat con IA (mascota) es la capa extra cuando hay conexión — ver features/mascota/ +final separationGuideProvider = FutureProvider((ref) async { + // keepAlive: provider is non-autoDispose to avoid reloading the JSON on + // each navigation. This makes the guide work offline without repeated IO. + final jsonString = await rootBundle.loadString( + 'assets/data/separation_guide.json', + ); + final jsonResponse = json.decode(jsonString) as Map; + return SeparationGuide.fromJson(jsonResponse); +}); diff --git a/recolecta_app/lib/features/separation_guide/screens/category_detail_screen.dart b/recolecta_app/lib/features/separation_guide/screens/category_detail_screen.dart new file mode 100644 index 0000000..5291705 --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/screens/category_detail_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart' + as guide; +import 'package:recolecta_app/features/separation_guide/providers/separation_guide_provider.dart'; + +class CategoryDetailScreen extends ConsumerWidget { + final String categoryId; + + const CategoryDetailScreen({super.key, required this.categoryId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final guideData = ref.watch(separationGuideProvider); + + return Scaffold( + body: guideData.when( + data: (data) { + final category = data.categorias.firstWhere( + (c) => c.id == categoryId, + ); + return CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(category.nombre), + backgroundColor: category.colorValue, + expandedHeight: 120, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + background: Container( + color: category.colorValue, + alignment: Alignment.bottomLeft, + padding: const EdgeInsets.only(left: 16, bottom: 24), + child: Text( + category.descripcion, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.white), + ), + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final example = category.ejemplos[index]; + return ListTile( + leading: Icon( + example.acepta ? Icons.check_circle : Icons.cancel, + color: example.acepta ? Colors.green : Colors.red, + ), + title: Text(example.nombre), + subtitle: example.razon != null + ? Chip( + label: Text(example.razon!), + backgroundColor: Colors.red.withOpacity(0.1), + ) + : null, + ); + }, childCount: category.ejemplos.length), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + ), + bottomNavigationBar: guideData.maybeWhen( + data: (data) { + final category = data.categorias.firstWhere( + (c) => c.id == categoryId, + ); + return BottomAppBar( + color: category.colorValue, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + category.consejo, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ); + }, + orElse: () => null, + ), + ); + } +} diff --git a/recolecta_app/lib/features/separation_guide/screens/separation_guide_screen.dart b/recolecta_app/lib/features/separation_guide/screens/separation_guide_screen.dart new file mode 100644 index 0000000..d2ef062 --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/screens/separation_guide_screen.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:recolecta_app/features/separation_guide/providers/separation_guide_provider.dart'; +import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart' + as guide; + +class SeparationGuideScreen extends ConsumerWidget { + const SeparationGuideScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final guideData = ref.watch(separationGuideProvider); + return Scaffold( + appBar: AppBar( + title: const Text('Guía de Separación'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(20.0), + child: Text( + 'Funciona sin internet', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ), + body: guideData.when( + data: (data) => GridView.builder( + padding: const EdgeInsets.all(16.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16.0, + mainAxisSpacing: 16.0, + childAspectRatio: 1.2, + ), + itemCount: data.categorias.length, + itemBuilder: (context, index) { + final category = data.categorias[index]; + return CategoryCard(category: category); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + ), + ); + } +} + +class CategoryCard extends StatelessWidget { + const CategoryCard({super.key, required this.category}); + + final guide.Category category; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => context.go('/guide/${category.id}'), + child: Card( + color: category.colorValue.withOpacity(0.15), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: category.colorValue, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(category.iconData, size: 40, color: category.colorValue), + const SizedBox(height: 12), + Text( + category.nombre, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: category.colorValue, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/main.dart b/recolecta_app/lib/main.dart index 79706f0..ec7e314 100644 --- a/recolecta_app/lib/main.dart +++ b/recolecta_app/lib/main.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'app/app.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env'); runApp(const ProviderScope(child: RecolectaApp())); } diff --git a/recolecta_app/pubspec.lock b/recolecta_app/pubspec.lock index d3dfddc..3bf8624 100644 --- a/recolecta_app/pubspec.lock +++ b/recolecta_app/pubspec.lock @@ -270,6 +270,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" + url: "https://pub.dev" + source: hosted + version: "6.2.1" flutter_riverpod: dependency: "direct main" description: @@ -392,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -424,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -456,6 +480,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -488,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -600,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" pool: dependency: transitive description: @@ -608,6 +664,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" pub_semver: dependency: transitive description: @@ -821,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" uuid: dependency: transitive description: @@ -893,6 +965,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index aafcb45..b2d501a 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -2,7 +2,7 @@ name: recolecta_app description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -42,6 +42,8 @@ dependencies: firebase_messaging: ^15.1.5 flutter_secure_storage: ^9.2.4 cached_network_image: ^3.4.1 + flutter_map: ^6.1.0 + latlong2: ^0.9.0 dev_dependencies: flutter_test: @@ -60,7 +62,8 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: assets: - - assets/.env + # - assets/images/ + - assets/data/separation_guide.json # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/recolecta_app/test/widget_test.dart b/recolecta_app/test/widget_test.dart index 8fe2ac6..1f1adb6 100644 --- a/recolecta_app/test/widget_test.dart +++ b/recolecta_app/test/widget_test.dart @@ -1,30 +1,7 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:recolecta_app/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('placeholder smoke test', (tester) async { + expect(true, isTrue); }); } diff --git a/views_v2/eta_model.dart b/views_v2/eta_model.dart new file mode 100644 index 0000000..5ed990f --- /dev/null +++ b/views_v2/eta_model.dart @@ -0,0 +1,73 @@ +// lib/features/eta/eta_model.dart +// Modelo de respuesta del endpoint GET /eta?address_id=X +// El backend NUNCA devuelve coordenadas; solo texto y status. + +enum RouteStatus { + pendiente, + enRuta, + completada, + diferida, + reasignada, +} + +RouteStatus routeStatusFromString(String s) { + switch (s) { + case 'en_ruta': + return RouteStatus.enRuta; + case 'completada': + return RouteStatus.completada; + case 'diferida': + return RouteStatus.diferida; + case 'reasignada': + return RouteStatus.reasignada; + default: + return RouteStatus.pendiente; + } +} + +class EtaResponse { + /// Texto accionable que muestra el ciudadano. + /// Ejemplos: "Llega en aproximadamente 15 minutos" + /// "Servicio del día finalizado" + final String mensaje; + + /// Estado de la ruta para mostrar el badge correcto. + final RouteStatus status; + + /// Ventana horaria opcional, ej. "7:20–7:35 p.m." + /// Solo presente cuando positionId == 4 (TRUCK_PROXIMITY). + final String? ventanaHoraria; + + const EtaResponse({ + required this.mensaje, + required this.status, + this.ventanaHoraria, + }); + + factory EtaResponse.fromJson(Map json) { + return EtaResponse( + mensaje: json['mensaje'] as String, + status: routeStatusFromString(json['status'] as String), + ventanaHoraria: json['ventana_horaria'] as String?, + ); + } + + /// Estado de progreso local (0-3) mapeado al positionId del backend. + /// Útil para la barra de 4 pasos en la UI. + int get stepIndex { + switch (status) { + case RouteStatus.pendiente: + return 0; + case RouteStatus.enRuta: + return 1; + case RouteStatus.completada: + return 3; + default: + return 2; + } + } + + bool get isCompleted => status == RouteStatus.completada; + bool get isNearby => + ventanaHoraria != null && status == RouteStatus.enRuta; +} \ No newline at end of file diff --git a/views_v2/eta_provider.dart b/views_v2/eta_provider.dart new file mode 100644 index 0000000..613cadd --- /dev/null +++ b/views_v2/eta_provider.dart @@ -0,0 +1,41 @@ +// lib/features/eta/eta_provider.dart +// Riverpod AsyncNotifier: carga ETA al abrir la app y al recibir push FCM. +// No hace polling continuo. + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'eta_model.dart'; +import 'eta_service.dart'; + +// ────────────────────────────────────────── +// Provider del addressId activo del ciudadano +// (se puebla en el provider de auth/session) +// ────────────────────────────────────────── +final activeAddressIdProvider = StateProvider((ref) => null); + +// ────────────────────────────────────────── +// AsyncNotifier principal de ETA +// ────────────────────────────────────────── +class EtaNotifier extends AsyncNotifier { + @override + Future build() async { + final addressId = ref.watch(activeAddressIdProvider); + if (addressId == null) { + throw Exception('No hay domicilio verificado'); + } + return ref.read(etaServiceProvider).fetchEta(addressId); + } + + /// Llamar desde la UI (botón refrescar) o desde el handler de FCM. + Future refresh() async { + state = const AsyncLoading(); + final addressId = ref.read(activeAddressIdProvider); + if (addressId == null) return; + state = await AsyncValue.guard( + () => ref.read(etaServiceProvider).fetchEta(addressId), + ); + } +} + +final etaProvider = AsyncNotifierProvider( + EtaNotifier.new, +); \ No newline at end of file diff --git a/views_v2/eta_screen.dart b/views_v2/eta_screen.dart new file mode 100644 index 0000000..325ecb9 --- /dev/null +++ b/views_v2/eta_screen.dart @@ -0,0 +1,385 @@ +// lib/features/eta/eta_screen.dart +// Vista principal del ciudadano: ETA sin mapa ni coordenadas. +// Se refresca en initState y al recibir push FCM (vía NotificationService). + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'eta_model.dart'; +import 'eta_provider.dart'; +import '../notifications/notification_service.dart'; +import '../../shared/widgets/prevention_banner.dart'; +import '../../shared/widgets/progress_steps.dart'; + +class EtaScreen extends ConsumerStatefulWidget { + const EtaScreen({super.key}); + + @override + ConsumerState createState() => _EtaScreenState(); +} + +class _EtaScreenState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // Escuchar pushes en foreground: refrescar ETA al recibir cualquier + // evento FCM de RUTA_PROXIMITY o ROUTE_START. + NotificationService.onFcmMessage.addListener(_onPush); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + NotificationService.onFcmMessage.removeListener(_onPush); + super.dispose(); + } + + /// Refresca cuando la app vuelve al foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(etaProvider.notifier).refresh(); + } + } + + void _onPush() { + ref.read(etaProvider.notifier).refresh(); + } + + @override + Widget build(BuildContext context) { + final etaAsync = ref.watch(etaProvider); + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + appBar: AppBar( + title: const Text('Mi recolección'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Actualizar', + onPressed: () => ref.read(etaProvider.notifier).refresh(), + ), + ], + ), + body: etaAsync.when( + loading: () => const _EtaLoading(), + error: (e, _) => _EtaError(message: e.toString()), + data: (eta) => _EtaContent(eta: eta), + ), + ); + } +} + +// ────────────────────────────────────────── +// Contenido principal cuando hay datos +// ────────────────────────────────────────── +class _EtaContent extends StatelessWidget { + final EtaResponse eta; + const _EtaContent({required this.eta}); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () => + ProviderScope.containerOf(context).read(etaProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + _EtaHeroCard(eta: eta), + const SizedBox(height: 12), + const PreventionBanner(), + const SizedBox(height: 12), + ProgressSteps(stepIndex: eta.stepIndex), + const SizedBox(height: 12), + const _FcmStatusBadge(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +// ────────────────────────────────────────── +// Hero card con ventana horaria y mensaje +// ────────────────────────────────────────── +class _EtaHeroCard extends StatelessWidget { + final EtaResponse eta; + const _EtaHeroCard({required this.eta}); + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (eta.isCompleted) return cs.surfaceContainerHighest; + if (eta.isNearby) return const Color(0xFFFFF8E1); // amber-50 equivalente + return const Color(0xFFE1F5EE); // teal-50 + } + + Color _accentColor(BuildContext context) { + if (eta.isCompleted) return Theme.of(context).colorScheme.outline; + if (eta.isNearby) return const Color(0xFFBA7517); // amber-400 + return const Color(0xFF1D9E75); // teal-400 + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(context); + final textTheme = Theme.of(context).textTheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: _bgColor(context), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: accent.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status pill + _StatusPill(eta: eta, accent: accent), + const SizedBox(height: 10), + // Ventana horaria o estado + Text( + eta.ventanaHoraria ?? _windowLabel(eta.status), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + height: 1.1, + ), + ), + const SizedBox(height: 6), + Text( + eta.mensaje, + style: textTheme.bodyMedium?.copyWith( + color: accent.withOpacity(0.85), + height: 1.45, + ), + ), + ], + ), + ); + } + + String _windowLabel(RouteStatus s) { + switch (s) { + case RouteStatus.completada: + return 'Servicio finalizado'; + case RouteStatus.diferida: + return 'Servicio diferido'; + case RouteStatus.reasignada: + return 'Ruta reasignada'; + default: + return 'En camino'; + } + } +} + +class _StatusPill extends StatelessWidget { + final EtaResponse eta; + final Color accent; + const _StatusPill({required this.eta, required this.accent}); + + @override + Widget build(BuildContext context) { + final label = eta.isNearby + ? 'Cerca de tu domicilio' + : eta.isCompleted + ? 'Servicio completado' + : 'En camino a tu sector'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!eta.isCompleted) + _PulsingDot(color: accent), + if (!eta.isCompleted) const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: accent, + ), + ), + ), + ], + ); + } +} + +class _PulsingDot extends StatefulWidget { + final Color color; + const _PulsingDot({required this.color}); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _anim = Tween(begin: 1.0, end: 0.3).animate(_ctrl); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Opacity( + opacity: _anim.value, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +// ────────────────────────────────────────── +// Badge de suscripción FCM +// ────────────────────────────────────────── +class _FcmStatusBadge extends ConsumerWidget { + const _FcmStatusBadge(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final routeId = ref.watch(activeRouteIdProvider); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Notificaciones activas ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: routeId != null + ? 'para topic_$routeId' + : '— suscribiendo...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// Expone el routeId activo (se puebla desde el provider de sesión/domicilio) +final activeRouteIdProvider = StateProvider((ref) => null); + +// ────────────────────────────────────────── +// Estados de carga y error +// ────────────────────────────────────────── +class _EtaLoading extends StatelessWidget { + const _EtaLoading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator.adaptive(), + SizedBox(height: 12), + Text('Consultando estado del servicio...'), + ], + ), + ); + } +} + +class _EtaError extends StatelessWidget { + final String message; + const _EtaError({required this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off_rounded, size: 48, color: Colors.grey), + const SizedBox(height: 12), + const Text( + 'No se pudo obtener el estado', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Text( + message, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => + ProviderScope.containerOf(context) + .read(etaProvider.notifier) + .refresh(), + icon: const Icon(Icons.refresh_rounded), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/views_v2/eta_serviser.dart b/views_v2/eta_serviser.dart new file mode 100644 index 0000000..080c555 --- /dev/null +++ b/views_v2/eta_serviser.dart @@ -0,0 +1,25 @@ +// lib/features/eta/eta_service.dart +// Llama a GET /eta?address_id=X via dio. +// La respuesta NUNCA contiene coordenadas (validado en backend + RLS). + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/dio_client.dart'; +import 'eta_model.dart'; + +class EtaService { + final Dio _dio; + EtaService(this._dio); + + Future fetchEta(String addressId) async { + final response = await _dio.get>( + '/eta', + queryParameters: {'address_id': addressId}, + ); + return EtaResponse.fromJson(response.data!); + } +} + +final etaServiceProvider = Provider( + (ref) => EtaService(ref.read(dioProvider)), +); \ No newline at end of file diff --git a/views_v2/notification_service.dart b/views_v2/notification_service.dart new file mode 100644 index 0000000..587c9ef --- /dev/null +++ b/views_v2/notification_service.dart @@ -0,0 +1,130 @@ +// lib/features/notifications/notification_service.dart +// Gestiona FCM: suscripción a topic, handlers foreground/background. +// +// Regla de privacidad: los payloads de push NUNCA contienen lat/lng. +// El backend solo manda title/body desde notificaciones.json. + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +// Canal Android de alta prioridad para alertas de proximidad +const _kChannelId = 'recolecta_alerts'; +const _kChannelName = 'Alertas de recolección'; +const _kChannelDesc = 'Notificaciones de llegada del camión recolector'; + +/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling. +class _FcmMessageNotifier extends ChangeNotifier { + RemoteMessage? lastMessage; + void notify(RemoteMessage msg) { + lastMessage = msg; + notifyListeners(); + } +} + +// Handler de background/terminated (top-level, fuera de clase) +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground. + debugPrint('[FCM background] ${message.notification?.title}'); +} + +class NotificationService { + NotificationService._(); + + static final _messaging = FirebaseMessaging.instance; + static final _localNotifications = FlutterLocalNotificationsPlugin(); + static final onFcmMessage = _FcmMessageNotifier(); + + /// Inicializar una sola vez en main.dart + static Future initialize() async { + // Registrar handler de background + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Solicitar permisos (iOS + Android 13+) + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + debugPrint('[FCM] Permission: ${settings.authorizationStatus}'); + + // Canal Android + const androidChannel = AndroidNotificationChannel( + _kChannelId, + _kChannelName, + description: _kChannelDesc, + importance: Importance.high, + ); + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(androidChannel); + + // Inicializar flutter_local_notifications + const initSettings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + ); + await _localNotifications.initialize(initSettings); + + // Foreground: mostrar notificación local + notificar EtaScreen + FirebaseMessaging.onMessage.listen((message) { + _showLocalNotification(message); + onFcmMessage.notify(message); + }); + + // Tap en notificación cuando la app estaba en background + FirebaseMessaging.onMessageOpenedApp.listen((message) { + onFcmMessage.notify(message); + }); + + // Verificar si la app abrió desde una notificación (terminated) + final initial = await _messaging.getInitialMessage(); + if (initial != null) { + onFcmMessage.notify(initial); + } + } + + /// Suscribir al topic de la ruta del ciudadano. + /// Llamar justo después de que verified = true en el domicilio. + static Future subscribeToRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.subscribeToTopic(topic); + debugPrint('[FCM] Suscrito a $topic'); + } + + /// Desuscribir (al cambiar de domicilio / colonia) + static Future unsubscribeFromRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.unsubscribeFromTopic(topic); + debugPrint('[FCM] Desuscrito de $topic'); + } + + static Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + if (notification == null) return; + + // El payload del backend es solo title+body; NUNCA contiene coordenadas. + await _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + _kChannelId, + _kChannelName, + channelDescription: _kChannelDesc, + importance: Importance.high, + priority: Priority.high, + // Sin ningún campo de mapa o ubicación + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + ); + } +} \ No newline at end of file diff --git a/viewsv1/views/.gitignore b/viewsv1/views/.gitignore deleted file mode 100644 index 6f0d006..0000000 --- a/viewsv1/views/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/viewsv1/views/.metadata b/viewsv1/views/.metadata deleted file mode 100644 index 7457e0e..0000000 --- a/viewsv1/views/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: windows - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/viewsv1/views/README.md b/viewsv1/views/README.md deleted file mode 100644 index 9be068e..0000000 --- a/viewsv1/views/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# rutaverde - -A new Flutter project. diff --git a/viewsv1/views/analysis_options.yaml b/viewsv1/views/analysis_options.yaml deleted file mode 100644 index 8e4c4f5..0000000 --- a/viewsv1/views/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:flutter_lints/flutter.yaml diff --git a/viewsv1/views/lib/main.dart b/viewsv1/views/lib/main.dart deleted file mode 100644 index d891524..0000000 --- a/viewsv1/views/lib/main.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'theme/app_theme.dart'; -import 'screens/splash_screen.dart'; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - ), - ); - runApp(const RutaVerdeApp()); -} - -class RutaVerdeApp extends StatelessWidget { - const RutaVerdeApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'RutaVerde', - debugShowCheckedModeBanner: false, - theme: AppTheme.lightTheme, - home: const SplashScreen(), - ); - } -} diff --git a/viewsv1/views/lib/screens/admin_screen.dart b/viewsv1/views/lib/screens/admin_screen.dart deleted file mode 100644 index a783cec..0000000 --- a/viewsv1/views/lib/screens/admin_screen.dart +++ /dev/null @@ -1,812 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../widgets/widgets.dart' as w; - -enum TruckStatus { disponible, enRuta, mantenimiento, detenido } - -extension TruckStatusX on TruckStatus { - String get label { - switch (this) { - case TruckStatus.disponible: - return 'Disponible'; - case TruckStatus.enRuta: - return 'En ruta'; - case TruckStatus.mantenimiento: - return 'Mantenimiento'; - case TruckStatus.detenido: - return 'Detenido'; - } - } - - w.StatusBadge get badge { - switch (this) { - case TruckStatus.disponible: - return w.StatusBadge.green(label); - case TruckStatus.enRuta: - return w.StatusBadge.amber(label); - case TruckStatus.mantenimiento: - return w.StatusBadge.gray(label); - case TruckStatus.detenido: - return w.StatusBadge.gray(label); - } - } -} - -class AdminUser { - final String id; - final String nombre; - final String apellido; - final String email; - final String telefono; - - const AdminUser({ - required this.id, - required this.nombre, - required this.apellido, - required this.email, - required this.telefono, - }); - - String get nombreCompleto => '$nombre $apellido'; - String get iniciales => - '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' - .toUpperCase(); - - AdminUser copyWith({ - String? nombre, - String? apellido, - String? email, - String? telefono, - }) { - return AdminUser( - id: id, - nombre: nombre ?? this.nombre, - apellido: apellido ?? this.apellido, - email: email ?? this.email, - telefono: telefono ?? this.telefono, - ); - } -} - -class AdminRoute { - final String id; - final String nombre; - final String zona; - final bool activa; - - const AdminRoute({ - required this.id, - required this.nombre, - required this.zona, - this.activa = true, - }); - - AdminRoute copyWith({ - String? nombre, - String? zona, - bool? activa, - }) { - return AdminRoute( - id: id, - nombre: nombre ?? this.nombre, - zona: zona ?? this.zona, - activa: activa ?? this.activa, - ); - } -} - -class AdminTruck { - final String id; - final String placas; - final String modelo; - final String conductor; - final TruckStatus status; - final String rutaId; - - const AdminTruck({ - required this.id, - required this.placas, - required this.modelo, - required this.conductor, - required this.status, - required this.rutaId, - }); - - AdminTruck copyWith({ - String? placas, - String? modelo, - String? conductor, - TruckStatus? status, - String? rutaId, - }) { - return AdminTruck( - id: id, - placas: placas ?? this.placas, - modelo: modelo ?? this.modelo, - conductor: conductor ?? this.conductor, - status: status ?? this.status, - rutaId: rutaId ?? this.rutaId, - ); - } -} - -class AdminScreen extends StatefulWidget { - const AdminScreen({super.key}); - - @override - State createState() => _AdminScreenState(); -} - -class _AdminScreenState extends State - with SingleTickerProviderStateMixin { - late final TabController _tabController; - int _activeTab = 0; - - final List _usuarios = [ - const AdminUser( - id: 'user-01', - nombre: 'Laura', - apellido: 'Gómez', - email: 'laura.gomez@rutaverde.com', - telefono: '+52 461 987 1234', - ), - const AdminUser( - id: 'user-02', - nombre: 'Miguel', - apellido: 'Sánchez', - email: 'miguel.sanchez@rutaverde.com', - telefono: '+52 461 123 7890', - ), - ]; - - final List _rutas = [ - const AdminRoute( - id: 'ruta-01', - nombre: 'Ruta Norte', - zona: 'Zona Norte', - ), - const AdminRoute( - id: 'ruta-02', - nombre: 'Ruta Sur', - zona: 'Zona Sur', - activa: false, - ), - ]; - - final List _camiones = [ - const AdminTruck( - id: 'truck-01', - placas: 'ABC-1234', - modelo: 'Volvo FH', - conductor: 'Javier Pérez', - status: TruckStatus.enRuta, - rutaId: 'ruta-01', - ), - const AdminTruck( - id: 'truck-02', - placas: 'DEF-5678', - modelo: 'Mercedes 1830', - conductor: 'Ana Díaz', - status: TruckStatus.disponible, - rutaId: 'ruta-02', - ), - ]; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this) - ..addListener(() { - if (_tabController.indexIsChanging) return; - setState(() => _activeTab = _tabController.index); - }); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: const Text('Panel de administración'), - bottom: TabBar( - controller: _tabController, - indicatorColor: AppTheme.primary, - tabs: const [ - Tab(text: 'Usuarios'), - Tab(text: 'Rutas'), - Tab(text: 'Camiones'), - ], - ), - ), - body: TabBarView( - controller: _tabController, - children: [ - _buildUsersTab(), - _buildRoutesTab(), - _buildTrucksTab(), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - if (_activeTab == 0) { - _showUserForm(); - } else if (_activeTab == 1) { - _showRouteForm(); - } else { - _showTruckForm(); - } - }, - label: Text(_activeTab == 0 - ? 'Nuevo usuario' - : _activeTab == 1 - ? 'Nueva ruta' - : 'Nuevo camión'), - icon: const Icon(Icons.add), - ), - ); - } - - Widget _buildUsersTab() { - if (_usuarios.isEmpty) { - return _buildEmptyState('No hay usuarios registrados aún.'); - } - - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _usuarios.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final user = _usuarios[index]; - return w.AppCard( - child: Row( - children: [ - CircleAvatar( - backgroundColor: AppTheme.primaryLight, - foregroundColor: AppTheme.primary, - child: Text(user.iniciales), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user.nombreCompleto, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(user.email, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const SizedBox(height: 2), - Text(user.telefono, style: const TextStyle(fontSize: 13)), - ], - ), - ), - IconButton( - icon: const Icon(Icons.edit_outlined, color: AppTheme.primary), - onPressed: () => _showUserForm(user: user), - ), - IconButton( - icon: const Icon(Icons.delete_outline, color: AppTheme.danger), - onPressed: () => _confirmDeleteUser(user), - ), - ], - ), - ); - }, - ); - } - - Widget _buildRoutesTab() { - if (_rutas.isEmpty) { - return _buildEmptyState('No hay rutas registradas aún.'); - } - - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _rutas.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final ruta = _rutas[index]; - return w.AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(ruta.nombre, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w600)), - ), - Text(ruta.activa ? 'Activa' : 'Inactiva', - style: TextStyle( - fontSize: 13, - color: ruta.activa - ? AppTheme.primary - : AppTheme.textSecondary)), - ], - ), - const SizedBox(height: 8), - Text('Zona ${ruta.zona}', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => _showRouteForm(route: ruta), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Editar'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => _confirmDeleteRoute(ruta), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Eliminar'), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Widget _buildTrucksTab() { - if (_camiones.isEmpty) { - return _buildEmptyState('No hay camiones registrados aún.'); - } - - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _camiones.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final truck = _camiones[index]; - final route = _rutas.firstWhere( - (route) => route.id == truck.rutaId, - orElse: () => - const AdminRoute(id: 'none', nombre: 'Sin ruta', zona: ''), - ); - - return w.AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(truck.placas, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w600)), - ), - truck.status.badge, - ], - ), - const SizedBox(height: 8), - Text('${truck.modelo} · ${truck.conductor}', - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 4), - Text('Ruta: ${route.nombre}', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => _showTruckForm(truck: truck), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Editar'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => _confirmDeleteTruck(truck), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Eliminar'), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Widget _buildEmptyState(String message) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text(message, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 15, - color: AppTheme.textSecondary, - )), - ), - ); - } - - void _confirmDeleteUser(AdminUser user) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar usuario'), - content: const Text('¿Deseas eliminar este usuario?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - setState( - () => _usuarios.removeWhere((item) => item.id == user.id)); - Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); - } - - void _confirmDeleteRoute(AdminRoute route) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar ruta'), - content: const Text('¿Deseas eliminar esta ruta?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - setState(() => _rutas.removeWhere((item) => item.id == route.id)); - Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); - } - - void _confirmDeleteTruck(AdminTruck truck) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar camión'), - content: const Text('¿Deseas eliminar este camión?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - setState( - () => _camiones.removeWhere((item) => item.id == truck.id)); - Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); - } - - void _showUserForm({AdminUser? user}) { - final formKey = GlobalKey(); - final nombreCtrl = TextEditingController(text: user?.nombre); - final apellidoCtrl = TextEditingController(text: user?.apellido); - final emailCtrl = TextEditingController(text: user?.email); - final telefonoCtrl = TextEditingController(text: user?.telefono); - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(user == null ? 'Nuevo usuario' : 'Editar usuario'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: nombreCtrl, - decoration: const InputDecoration(labelText: 'Nombre'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: apellidoCtrl, - decoration: const InputDecoration(labelText: 'Apellido'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: emailCtrl, - decoration: const InputDecoration(labelText: 'Correo'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: telefonoCtrl, - decoration: const InputDecoration(labelText: 'Teléfono'), - keyboardType: TextInputType.phone, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - if (!formKey.currentState!.validate()) return; - final nuevo = AdminUser( - id: user?.id ?? 'user-${DateTime.now().millisecondsSinceEpoch}', - nombre: nombreCtrl.text.trim(), - apellido: apellidoCtrl.text.trim(), - email: emailCtrl.text.trim(), - telefono: telefonoCtrl.text.trim(), - ); - setState(() { - if (user == null) { - _usuarios.add(nuevo); - } else { - final index = - _usuarios.indexWhere((item) => item.id == user.id); - if (index >= 0) _usuarios[index] = nuevo; - } - }); - Navigator.pop(ctx); - }, - child: Text(user == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ); - } - - void _showRouteForm({AdminRoute? route}) { - final formKey = GlobalKey(); - final nombreCtrl = TextEditingController(text: route?.nombre); - final zonaCtrl = TextEditingController(text: route?.zona); - bool activa = route?.activa ?? true; - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: nombreCtrl, - decoration: - const InputDecoration(labelText: 'Nombre de ruta'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: zonaCtrl, - decoration: const InputDecoration(labelText: 'Zona'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - const SizedBox(height: 12), - Row( - children: [ - const Expanded(child: Text('Ruta activa')), - Switch.adaptive( - value: activa, - onChanged: (value) => setState(() { - activa = value; - }), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - if (!formKey.currentState!.validate()) return; - final nueva = AdminRoute( - id: route?.id ?? - 'ruta-${DateTime.now().millisecondsSinceEpoch}', - nombre: nombreCtrl.text.trim(), - zona: zonaCtrl.text.trim(), - activa: activa, - ); - setState(() { - if (route == null) { - _rutas.add(nueva); - } else { - final index = - _rutas.indexWhere((item) => item.id == route.id); - if (index >= 0) _rutas[index] = nueva; - } - }); - Navigator.pop(ctx); - }, - child: Text(route == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ); - } - - void _showTruckForm({AdminTruck? truck}) { - final formKey = GlobalKey(); - final placasCtrl = TextEditingController(text: truck?.placas); - final modeloCtrl = TextEditingController(text: truck?.modelo); - final conductorCtrl = TextEditingController(text: truck?.conductor); - TruckStatus status = truck?.status ?? TruckStatus.disponible; - String selectedRuta = - truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : ''); - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: placasCtrl, - decoration: const InputDecoration(labelText: 'Placas'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: modeloCtrl, - decoration: const InputDecoration(labelText: 'Modelo'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: conductorCtrl, - decoration: const InputDecoration(labelText: 'Conductor'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: selectedRuta.isEmpty ? null : selectedRuta, - decoration: const InputDecoration(labelText: 'Ruta'), - items: _rutas - .map((ruta) => DropdownMenuItem( - value: ruta.id, - child: Text(ruta.nombre), - )) - .toList(), - onChanged: (value) { - if (value != null) { - selectedRuta = value; - } - }, - validator: (value) => - value == null || value.isEmpty ? 'Requerido' : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: status, - decoration: const InputDecoration(labelText: 'Estatus'), - items: TruckStatus.values - .map((item) => DropdownMenuItem( - value: item, - child: Text(item.label), - )) - .toList(), - onChanged: (value) { - if (value != null) { - status = value; - } - }, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - if (!formKey.currentState!.validate()) return; - final nuevo = AdminTruck( - id: truck?.id ?? - 'truck-${DateTime.now().millisecondsSinceEpoch}', - placas: placasCtrl.text.trim(), - modelo: modeloCtrl.text.trim(), - conductor: conductorCtrl.text.trim(), - status: status, - rutaId: selectedRuta, - ); - setState(() { - if (truck == null) { - _camiones.add(nuevo); - } else { - final index = - _camiones.indexWhere((item) => item.id == truck.id); - if (index >= 0) _camiones[index] = nuevo; - } - }); - Navigator.pop(ctx); - }, - child: Text(truck == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ); - } -} diff --git a/viewsv1/views/lib/screens/login_screen.dart b/viewsv1/views/lib/screens/login_screen.dart deleted file mode 100644 index cc4366c..0000000 --- a/viewsv1/views/lib/screens/login_screen.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../widgets/widgets.dart' as w; -import 'main_shell.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _formKey = GlobalKey(); - final _emailCtrl = TextEditingController(); - final _passCtrl = TextEditingController(); - bool _obscurePass = true; - bool _loading = false; - - @override - void dispose() { - _emailCtrl.dispose(); - _passCtrl.dispose(); - super.dispose(); - } - - Future _login() async { - if (!_formKey.currentState!.validate()) return; - setState(() => _loading = true); - await Future.delayed(const Duration(seconds: 1)); // Simular petición - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const MainShell()), - (_) => false, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - iconTheme: const IconThemeData(color: AppTheme.textPrimary), - title: const Text( - 'Iniciar sesión', - style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), - ), - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - - // ── Encabezado ───────────────────────────────────────── - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: - BorderRadius.circular(AppTheme.radiusMd), - ), - child: const Icon(Icons.delete_outline_rounded, - color: AppTheme.primary, size: 26), - ), - const SizedBox(width: 14), - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('RutaVerde', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - Text('Bienvenido de nuevo', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary)), - ], - ), - ], - ), - - const SizedBox(height: 32), - - // ── Formulario ───────────────────────────────────────── - w.FormField( - label: 'Correo electrónico', - hint: 'tu@correo.com', - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 16), - w.FormField( - label: 'Contraseña', - hint: '••••••••', - controller: _passCtrl, - obscureText: _obscurePass, - suffix: IconButton( - icon: Icon( - _obscurePass - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 18, - color: AppTheme.textSecondary, - ), - onPressed: () => - setState(() => _obscurePass = !_obscurePass), - ), - ), - - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - foregroundColor: AppTheme.primary), - child: const Text('¿Olvidaste tu contraseña?', - style: TextStyle(fontSize: 13)), - ), - ), - - const SizedBox(height: 24), - - // ── Botón ingresar ────────────────────────────────────── - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: _loading ? null : _login, - child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Text('Ingresar'), - ), - ), - - const SizedBox(height: 28), - - // ── Divisor ───────────────────────────────────────────── - Row( - children: [ - const Expanded(child: Divider(color: AppTheme.border)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text('o', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - ), - const Expanded(child: Divider(color: AppTheme.border)), - ], - ), - - const SizedBox(height: 20), - - // ── Continuar con Google ──────────────────────────────── - _SocialButton( - icon: Icons.g_mobiledata_rounded, - label: 'Continuar con Google', - onTap: () {}, - ), - - const SizedBox(height: 36), - - // ── Crear cuenta ──────────────────────────────────────── - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('¿No tienes cuenta? ', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - GestureDetector( - onTap: () => Navigator.pop(context), - child: const Text('Regístrate', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primary)), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _SocialButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _SocialButton( - {required this.icon, required this.label, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 13), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 22, color: AppTheme.textPrimary), - const SizedBox(width: 10), - Text(label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary)), - ], - ), - ), - ); - } -} diff --git a/viewsv1/views/lib/screens/map_screen.dart b/viewsv1/views/lib/screens/map_screen.dart deleted file mode 100644 index a32ebc8..0000000 --- a/viewsv1/views/lib/screens/map_screen.dart +++ /dev/null @@ -1,381 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import '../theme/app_theme.dart'; -import '../models/models.dart'; -import '../widgets/widgets.dart' as w; - -class MapScreen extends StatefulWidget { - const MapScreen({super.key}); - - @override - State createState() => _MapScreenState(); -} - -class _MapScreenState extends State { - final Completer _mapController = Completer(); - - // Coordenadas de ejemplo — Celaya, Gto. - static const LatLng _casaPos = LatLng(20.5226, -100.8191); - static const LatLng _camionPos = LatLng(20.5255, -100.8220); - static const CameraPosition _camaraInicial = CameraPosition( - target: LatLng(20.5240, -100.8205), - zoom: 15.5, - ); - - // Datos de ejemplo del camión - final TruckLocation _camion = TruckLocation( - id: 'truck-01', - ruta: 'Ruta Norte', - latitud: _camionPos.latitude, - longitud: _camionPos.longitude, - ultimaActualizacion: DateTime.now().subtract(const Duration(seconds: 28)), - enServicio: true, - ); - - final HouseModel _casa = HouseModel( - id: 'casa-01', - calle: 'Av. Insurgentes 245', - colonia: 'Centro', - codigoPostal: '38000', - latitud: _casaPos.latitude, - longitud: _casaPos.longitude, - radioAlertaMetros: 200, - ); - - Set _markers = {}; - Set _circles = {}; - Timer? _refreshTimer; - - // Distancia simulada (metros) - double get _distanciaMetros => 380; - int get _minutosEstimados => 8; - - @override - void initState() { - super.initState(); - _buildMapElements(); - // Simular actualización de posición cada 30s - _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _refreshTimer?.cancel(); - super.dispose(); - } - - void _buildMapElements() { - _markers = { - Marker( - markerId: const MarkerId('camion'), - position: LatLng(_camion.latitud, _camion.longitud), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), - infoWindow: InfoWindow( - title: 'Camión · ${_camion.ruta}', - snippet: _camion.tiempoActualizacion, - ), - ), - Marker( - markerId: const MarkerId('casa'), - position: LatLng(_casa.latitud, _casa.longitud), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue), - infoWindow: InfoWindow(title: _casa.alias, snippet: _casa.calle), - ), - }; - - _circles = { - Circle( - circleId: const CircleId('radio-alerta'), - center: LatLng(_casa.latitud, _casa.longitud), - radius: _casa.radioAlertaMetros.toDouble(), - fillColor: AppTheme.blue.withOpacity(0.08), - strokeColor: AppTheme.blue.withOpacity(0.4), - strokeWidth: 1, - ), - }; - } - - Future _centrarMapa() async { - final controller = await _mapController.future; - await controller.animateCamera( - CameraUpdate.newCameraPosition(_camaraInicial), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: const Text('Rastreo en vivo'), - actions: [ - IconButton( - icon: const Icon(Icons.my_location), - onPressed: _centrarMapa, - tooltip: 'Centrar mapa', - ), - ], - ), - body: Column( - children: [ - // ── Mapa ───────────────────────────────────────────────────── - Expanded( - flex: 5, - child: Stack( - children: [ - GoogleMap( - initialCameraPosition: _camaraInicial, - markers: _markers, - circles: _circles, - myLocationButtonEnabled: false, - zoomControlsEnabled: false, - mapType: MapType.normal, - onMapCreated: (c) { - _mapController.complete(c); - }, - ), - - // Indicador "En vivo" - Positioned( - top: 14, - right: 14, - child: _LiveBadge(activo: _camion.enServicio), - ), - - // Actualización - Positioned( - top: 14, - left: 14, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: AppTheme.softShadow, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.refresh, - size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text( - _camion.tiempoActualizacion, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary), - ), - ], - ), - ), - ), - ], - ), - ), - - // ── Panel inferior ──────────────────────────────────────────── - Expanded( - flex: 3, - child: Container( - decoration: BoxDecoration( - color: AppTheme.background, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppTheme.radiusXl)), - boxShadow: AppTheme.cardShadow, - ), - child: Column( - children: [ - // Handle - Container( - margin: const EdgeInsets.symmetric(vertical: 10), - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: BorderRadius.circular(4), - ), - ), - - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - // Camión - w.InfoRow( - icon: Icons.delete_outline_rounded, - label: '${_camion.ruta} · ${_camion.tiempoActualizacion}', - value: 'Camión a ${_distanciaMetros.toStringAsFixed(0)} m', - trailing: w.StatusBadge.amber('~$_minutosEstimados min'), - ), - const SizedBox(height: 10), - // Casa - w.InfoRow( - icon: Icons.home_outlined, - label: _casa.direccionCompleta, - value: _casa.alias, - trailing: w.StatusBadge.green('Activa'), - ), - const SizedBox(height: 12), - // Barra de progreso de llegada - _ArrivalBar( - distanciaActual: _distanciaMetros, - distanciaTotal: 1000, - minutos: _minutosEstimados, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -// ── Badge "En vivo" ─────────────────────────────────────────────────────────── -class _LiveBadge extends StatefulWidget { - final bool activo; - const _LiveBadge({required this.activo}); - - @override - State<_LiveBadge> createState() => _LiveBadgeState(); -} - -class _LiveBadgeState extends State<_LiveBadge> - with SingleTickerProviderStateMixin { - late AnimationController _anim; - - @override - void initState() { - super.initState(); - _anim = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - } - - @override - void dispose() { - _anim.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: AppTheme.softShadow, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: _anim, - builder: (_, __) => Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.activo - ? AppTheme.primary.withOpacity(0.5 + _anim.value * 0.5) - : AppTheme.textSecondary, - ), - ), - ), - const SizedBox(width: 5), - Text( - widget.activo ? 'En vivo' : 'Sin servicio', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: widget.activo ? AppTheme.primary : AppTheme.textSecondary, - ), - ), - ], - ), - ); - } -} - -// ── Barra de llegada estimada ───────────────────────────────────────────────── -class _ArrivalBar extends StatelessWidget { - final double distanciaActual; - final double distanciaTotal; - final int minutos; - - const _ArrivalBar({ - required this.distanciaActual, - required this.distanciaTotal, - required this.minutos, - }); - - @override - Widget build(BuildContext context) { - final progreso = - ((distanciaTotal - distanciaActual) / distanciaTotal).clamp(0.0, 1.0); - - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.primaryMid, width: 0.5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('Llegada estimada', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppTheme.primaryDark)), - const Spacer(), - Text('~$minutos min', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppTheme.primary)), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: progreso, - backgroundColor: AppTheme.primaryMid.withOpacity(0.4), - valueColor: - const AlwaysStoppedAnimation(AppTheme.primary), - minHeight: 6, - ), - ), - const SizedBox(height: 4), - Row( - children: const [ - Text('Ahora', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - Spacer(), - Text('Tu casa', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - ], - ), - ], - ), - ); - } -} diff --git a/viewsv1/views/lib/screens/profile_screen.dart b/viewsv1/views/lib/screens/profile_screen.dart deleted file mode 100644 index f148335..0000000 --- a/viewsv1/views/lib/screens/profile_screen.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../models/models.dart'; -import '../widgets/widgets.dart' as w; -import 'admin_screen.dart'; -import 'splash_screen.dart'; - -class ProfileScreen extends StatelessWidget { - const ProfileScreen({super.key}); - - final UserModel _usuario = const UserModel( - id: 'user-01', - nombre: 'Carlos', - apellido: 'Martínez', - email: 'carlos@ejemplo.com', - telefono: '+52 461 123 4567', - ); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Mi perfil')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // ── Avatar y datos ───────────────────────────────────────── - _ProfileHeader(usuario: _usuario), - - const SizedBox(height: 20), - - // ── Mi cuenta ────────────────────────────────────────────── - w.SectionTitle(title: 'Mi cuenta'), - w.MenuTile( - icon: Icons.person_outline, - title: 'Editar perfil', - subtitle: '${_usuario.nombre} ${_usuario.apellido}', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.lock_outline, - title: 'Cambiar contraseña', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.phone_outlined, - title: 'Teléfono', - subtitle: _usuario.telefono, - onTap: () {}, - ), - - const SizedBox(height: 16), - - // ── Configuración ────────────────────────────────────────── - w.SectionTitle(title: 'Configuración'), - w.MenuTile( - icon: Icons.calendar_month_outlined, - title: 'Horario del camión', - subtitle: 'Ruta Norte · Celaya', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.language_outlined, - title: 'Idioma', - subtitle: 'Español', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.dark_mode_outlined, - title: 'Tema', - subtitle: 'Claro', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.admin_panel_settings_outlined, - title: 'Panel de administración', - subtitle: 'Gestiona usuarios, rutas y camiones', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AdminScreen()), - ); - }, - ), - - const SizedBox(height: 16), - - // ── Soporte ──────────────────────────────────────────────── - w.SectionTitle(title: 'Soporte'), - w.MenuTile( - icon: Icons.help_outline, - title: 'Ayuda y preguntas frecuentes', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.bug_report_outlined, - title: 'Reportar un problema', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.info_outline, - title: 'Acerca de la app', - subtitle: 'Versión 1.0.0', - onTap: () {}, - ), - - const SizedBox(height: 16), - - // ── Cerrar sesión ────────────────────────────────────────── - w.MenuTile( - icon: Icons.logout_rounded, - title: 'Cerrar sesión', - iconColor: AppTheme.danger, - titleColor: AppTheme.danger, - trailing: const SizedBox.shrink(), - onTap: () => _confirmarCerrarSesion(context), - ), - - const SizedBox(height: 32), - - Center( - child: Text( - 'RutaVerde v1.0.0\nServicio de Limpia · Celaya, Gto.', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, color: AppTheme.textHint, height: 1.6), - ), - ), - - const SizedBox(height: 24), - ], - ), - ); - } - - void _confirmarCerrarSesion(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Cerrar sesión', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - content: const Text( - '¿Estás seguro de que deseas cerrar sesión?', - style: TextStyle(fontSize: 14, color: AppTheme.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const SplashScreen()), - (_) => false, - ); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Cerrar sesión', - style: TextStyle(fontWeight: FontWeight.w600)), - ), - ], - ), - ); - } -} - -// ── Encabezado de perfil ────────────────────────────────────────────────────── -class _ProfileHeader extends StatelessWidget { - final UserModel usuario; - const _ProfileHeader({required this.usuario}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Row( - children: [ - // Avatar con iniciales - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - shape: BoxShape.circle, - border: Border.all(color: AppTheme.primaryMid, width: 1.5), - ), - child: Center( - child: Text( - usuario.iniciales, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppTheme.primaryDark), - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - usuario.nombreCompleto, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary), - ), - const SizedBox(height: 2), - Text( - usuario.email, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary), - ), - const SizedBox(height: 6), - w.StatusBadge.green('Cuenta activa'), - ], - ), - ), - IconButton( - icon: const Icon(Icons.edit_outlined, - color: AppTheme.primary, size: 20), - onPressed: () {}, - ), - ], - ), - ); - } -} diff --git a/viewsv1/views/lib/screens/register_screen.dart b/viewsv1/views/lib/screens/register_screen.dart deleted file mode 100644 index 172f62d..0000000 --- a/viewsv1/views/lib/screens/register_screen.dart +++ /dev/null @@ -1,541 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../widgets/widgets.dart' as w; -import 'main_shell.dart'; - -class RegisterScreen extends StatefulWidget { - const RegisterScreen({super.key}); - - @override - State createState() => _RegisterScreenState(); -} - -class _RegisterScreenState extends State { - final _pageController = PageController(); - int _currentPage = 0; - bool _loading = false; - - // Paso 1 - final _nombreCtrl = TextEditingController(); - final _apellidoCtrl = TextEditingController(); - final _emailCtrl = TextEditingController(); - final _telefonoCtrl = TextEditingController(); - final _passCtrl = TextEditingController(); - bool _obscurePass = true; - - // Paso 2 - final _calleCtrl = TextEditingController(); - final _coloniaCtrl = TextEditingController(); - final _cpCtrl = TextEditingController(); - int _radioAlerta = 200; - - @override - void dispose() { - _pageController.dispose(); - _nombreCtrl.dispose(); _apellidoCtrl.dispose(); - _emailCtrl.dispose(); _telefonoCtrl.dispose(); _passCtrl.dispose(); - _calleCtrl.dispose(); _coloniaCtrl.dispose(); _cpCtrl.dispose(); - super.dispose(); - } - - void _nextPage() { - _pageController.nextPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - setState(() => _currentPage = 1); - } - - Future _register() async { - setState(() => _loading = true); - await Future.delayed(const Duration(seconds: 1)); - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const MainShell()), - (_) => false, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - iconTheme: const IconThemeData(color: AppTheme.textPrimary), - title: Text( - _currentPage == 0 ? 'Crear cuenta' : 'Mi dirección', - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(4), - child: _StepIndicator(current: _currentPage, total: 2), - ), - ), - body: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - children: [ - _Step1( - nombreCtrl: _nombreCtrl, - apellidoCtrl: _apellidoCtrl, - emailCtrl: _emailCtrl, - telefonoCtrl: _telefonoCtrl, - passCtrl: _passCtrl, - obscurePass: _obscurePass, - onTogglePass: () => setState(() => _obscurePass = !_obscurePass), - onNext: _nextPage, - ), - _Step2( - calleCtrl: _calleCtrl, - coloniaCtrl: _coloniaCtrl, - cpCtrl: _cpCtrl, - radioAlerta: _radioAlerta, - onRadioChanged: (v) => setState(() => _radioAlerta = v), - onRegister: _register, - loading: _loading, - ), - ], - ), - ); - } -} - -// ── Indicador de pasos ──────────────────────────────────────────────────────── -class _StepIndicator extends StatelessWidget { - final int current; - final int total; - - const _StepIndicator({required this.current, required this.total}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), - child: Row( - children: List.generate(total, (i) { - final active = i <= current; - return Expanded( - child: Container( - margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), - height: 4, - decoration: BoxDecoration( - color: active ? AppTheme.primary : AppTheme.border, - borderRadius: BorderRadius.circular(4), - ), - ), - ); - }), - ), - ); - } -} - -// ── Paso 1: Datos personales ────────────────────────────────────────────────── -class _Step1 extends StatelessWidget { - final TextEditingController nombreCtrl, apellidoCtrl, emailCtrl, - telefonoCtrl, passCtrl; - final bool obscurePass; - final VoidCallback onTogglePass; - final VoidCallback onNext; - - const _Step1({ - required this.nombreCtrl, required this.apellidoCtrl, - required this.emailCtrl, required this.telefonoCtrl, - required this.passCtrl, required this.obscurePass, - required this.onTogglePass, required this.onNext, - }); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - - // ── Sección personal ────────────────────────────────────────── - _FormCard( - icon: Icons.person_outline, - title: 'Información personal', - child: Column( - children: [ - Row( - children: [ - Expanded( - child: w.FormField( - label: 'Nombre', - hint: 'Carlos', - controller: nombreCtrl, - ), - ), - const SizedBox(width: 12), - Expanded( - child: w.FormField( - label: 'Apellido', - hint: 'Martínez', - controller: apellidoCtrl, - ), - ), - ], - ), - const SizedBox(height: 14), - w.FormField( - label: 'Correo electrónico', - hint: 'tu@correo.com', - controller: emailCtrl, - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 14), - w.FormField( - label: 'Teléfono', - hint: '+52 461 123 4567', - controller: telefonoCtrl, - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 14), - w.FormField( - label: 'Contraseña', - hint: '••••••••', - controller: passCtrl, - obscureText: obscurePass, - suffix: IconButton( - icon: Icon( - obscurePass - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 18, color: AppTheme.textSecondary, - ), - onPressed: onTogglePass, - ), - ), - ], - ), - ), - - const SizedBox(height: 28), - - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: onNext, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Text('Siguiente'), - SizedBox(width: 8), - Icon(Icons.arrow_forward, size: 18), - ], - ), - ), - ), - - const SizedBox(height: 20), - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('¿Ya tienes cuenta? ', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - GestureDetector( - onTap: () => Navigator.pop(context), - child: const Text('Inicia sesión', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primary)), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ── Paso 2: Dirección ───────────────────────────────────────────────────────── -class _Step2 extends StatelessWidget { - final TextEditingController calleCtrl, coloniaCtrl, cpCtrl; - final int radioAlerta; - final ValueChanged onRadioChanged; - final VoidCallback onRegister; - final bool loading; - - const _Step2({ - required this.calleCtrl, required this.coloniaCtrl, required this.cpCtrl, - required this.radioAlerta, required this.onRadioChanged, - required this.onRegister, required this.loading, - }); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - - _FormCard( - icon: Icons.home_outlined, - title: 'Dirección de tu casa', - child: Column( - children: [ - w.FormField( - label: 'Calle y número', - hint: 'Av. Insurgentes 245', - controller: calleCtrl, - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - flex: 3, - child: w.FormField( - label: 'Colonia', - hint: 'Centro', - controller: coloniaCtrl, - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: w.FormField( - label: 'C.P.', - hint: '38000', - controller: cpCtrl, - keyboardType: TextInputType.number, - ), - ), - ], - ), - const SizedBox(height: 14), - - // Usar ubicación actual - GestureDetector( - onTap: () {}, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 11, horizontal: 14), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: - BorderRadius.circular(AppTheme.radiusSm), - border: Border.all( - color: AppTheme.primaryMid, width: 0.5), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.my_location, - color: AppTheme.primary, size: 18), - SizedBox(width: 8), - Text('Usar mi ubicación actual', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppTheme.primaryDark)), - ], - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 16), - - _FormCard( - icon: Icons.notifications_outlined, - title: 'Distancia de alerta', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Te avisamos cuando el camión esté a esta distancia de tu casa.', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary, - height: 1.4), - ), - const SizedBox(height: 14), - ...([200, 400, 600]).map((dist) => _RadioOption( - value: dist, - groupValue: radioAlerta, - label: '$dist metros', - sublabel: dist == 200 - ? 'Alerta muy temprana (~2-3 min)' - : dist == 400 - ? 'Alerta temprana (~4-5 min)' - : 'Alerta anticipada (~6-8 min)', - onChanged: onRadioChanged, - )), - ], - ), - ), - - const SizedBox(height: 28), - - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: loading ? null : onRegister, - child: loading - ? const SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check, size: 18), - SizedBox(width: 8), - Text('Registrarme'), - ], - ), - ), - ), - - const SizedBox(height: 16), - Center( - child: Text( - 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary, height: 1.5), - ), - ), - ], - ), - ); - } -} - -// ── Tarjeta de formulario ───────────────────────────────────────────────────── -class _FormCard extends StatelessWidget { - final IconData icon; - final String title; - final Widget child; - - const _FormCard( - {required this.icon, required this.title, required this.child}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: AppTheme.primary, size: 18), - const SizedBox(width: 8), - Text(title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - ], - ), - const SizedBox(height: 16), - child, - ], - ), - ); - } -} - -// ── Opción radio ────────────────────────────────────────────────────────────── -class _RadioOption extends StatelessWidget { - final int value; - final int groupValue; - final String label; - final String sublabel; - final ValueChanged onChanged; - - const _RadioOption({ - required this.value, required this.groupValue, - required this.label, required this.sublabel, required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final selected = value == groupValue; - return GestureDetector( - onTap: () => onChanged(value), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), - decoration: BoxDecoration( - color: selected ? AppTheme.primaryLight : AppTheme.background, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all( - color: selected ? AppTheme.primary : AppTheme.border, - width: selected ? 1.5 : 0.5, - ), - ), - child: Row( - children: [ - Container( - width: 18, - height: 18, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: selected ? AppTheme.primary : AppTheme.border, - width: 2, - ), - ), - child: selected - ? Center( - child: Container( - width: 8, height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppTheme.primary, - ), - ), - ) - : null, - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: selected - ? AppTheme.primaryDark - : AppTheme.textPrimary)), - Text(sublabel, - style: TextStyle( - fontSize: 11, - color: selected - ? AppTheme.primary - : AppTheme.textSecondary)), - ], - ), - ], - ), - ), - ); - } -} diff --git a/viewsv1/views/lib/screens/splash_screen.dart b/viewsv1/views/lib/screens/splash_screen.dart deleted file mode 100644 index 1e7a0dd..0000000 --- a/viewsv1/views/lib/screens/splash_screen.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import 'login_screen.dart'; -import 'register_screen.dart'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _fadeIn; - late Animation _slideUp; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _fadeIn = Tween(begin: 0, end: 1).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeOut), - ); - _slideUp = Tween( - begin: const Offset(0, 0.3), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Container( - width: double.infinity, - height: double.infinity, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppTheme.primary, AppTheme.primaryDark], - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - children: [ - const Spacer(flex: 2), - - // ── Ícono de la app ───────────────────────────────────── - FadeTransition( - opacity: _fadeIn, - child: Container( - width: 90, - height: 90, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: - BorderRadius.circular(AppTheme.radiusXl), - ), - child: const Icon( - Icons.delete_outline_rounded, - size: 46, - color: Colors.white, - ), - ), - ), - - const SizedBox(height: 24), - - // ── Nombre y descripción ──────────────────────────────── - SlideTransition( - position: _slideUp, - child: FadeTransition( - opacity: _fadeIn, - child: Column( - children: [ - const Text( - 'RutaVerde', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 10), - Text( - 'Sigue en tiempo real el camión de basura\ny recibe alertas cuando esté cerca.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 15, - color: Colors.white.withOpacity(0.82), - height: 1.5, - ), - ), - ], - ), - ), - ), - - const Spacer(flex: 3), - - // ── Características rápidas ───────────────────────────── - FadeTransition( - opacity: _fadeIn, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _FeatureChip( - icon: Icons.location_on_outlined, - label: 'Rastreo en vivo', - ), - _FeatureChip( - icon: Icons.notifications_outlined, - label: 'Alertas', - ), - _FeatureChip( - icon: Icons.home_outlined, - label: 'Tu dirección', - ), - ], - ), - ), - - const SizedBox(height: 40), - - // ── Botones ───────────────────────────────────────────── - FadeTransition( - opacity: _fadeIn, - child: Column( - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppTheme.primaryDark, - minimumSize: const Size(double.infinity, 52), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(AppTheme.radiusMd), - ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const RegisterScreen(), - ), - ); - }, - child: const Text('Crear cuenta'), - ), - const SizedBox(height: 12), - OutlinedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LoginScreen(), - ), - ); - }, - child: const Text('Ya tengo cuenta'), - ), - ], - ), - ), - - const SizedBox(height: 24), - - Text( - 'Servicio de Limpia · Celaya, Gto.', - style: TextStyle( - fontSize: 12, - color: Colors.white.withOpacity(0.45), - ), - ), - - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } -} - -class _FeatureChip extends StatelessWidget { - final IconData icon; - final String label; - - const _FeatureChip({required this.icon, required this.label}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.12), - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: Colors.white.withOpacity(0.2)), - ), - child: Column( - children: [ - Icon(icon, color: Colors.white, size: 22), - const SizedBox(height: 5), - Text( - label, - style: const TextStyle( - fontSize: 11, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} diff --git a/viewsv1/views/pubspec.lock b/viewsv1/views/pubspec.lock deleted file mode 100644 index 4b0f5ad..0000000 --- a/viewsv1/views/pubspec.lock +++ /dev/null @@ -1,770 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "8f89e371e2883de35cdc78f648e725fa4da5f3b6c927269f00fa68f1ea92b598" - url: "https://pub.dev" - source: hosted - version: "1.3.71" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" - dbus: - dependency: transitive - description: - name: dbus - sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" - url: "https://pub.dev" - source: hosted - version: "0.7.13" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "93a5bde9775fd5adcc937f39dfa04ae0bc89c4d79bea6abc49de3f7b049d9ff6" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: "7c98f10b8c8e5adedc0b810b66a877120696675e2c22d9ca9caca092da0d9e57" - url: "https://pub.dev" - source: hosted - version: "3.7.0" - firebase_messaging: - dependency: "direct main" - description: - name: firebase_messaging - sha256: "8d0dc81a31cd030170508dc3e89bfd14355b20a1b991340af5f018e37daab5d7" - url: "https://pub.dev" - source: hosted - version: "16.2.2" - firebase_messaging_platform_interface: - dependency: transitive - description: - name: firebase_messaging_platform_interface - sha256: "37abb0b0535c5497605ee94c12470e1ebbbe47e71a22d0c20bffcc912311f8cb" - url: "https://pub.dev" - source: hosted - version: "4.7.11" - firebase_messaging_web: - dependency: transitive - description: - name: firebase_messaging_web - sha256: "54e22b43e2c26a2728a3f68c188de0f9011993ae19ae959a06d476dad935c776" - url: "https://pub.dev" - source: hosted - version: "4.1.7" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_local_notifications: - dependency: "direct main" - description: - name: flutter_local_notifications - sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 - url: "https://pub.dev" - source: hosted - version: "18.0.1" - flutter_local_notifications_linux: - dependency: transitive - description: - name: flutter_local_notifications_linux - sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - flutter_local_notifications_platform_interface: - dependency: transitive - description: - name: flutter_local_notifications_platform_interface - sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" - url: "https://pub.dev" - source: hosted - version: "2.0.34" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - geoclue: - dependency: transitive - description: - name: geoclue - sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f - url: "https://pub.dev" - source: hosted - version: "0.1.1" - geolocator: - dependency: "direct main" - description: - name: geolocator - sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" - url: "https://pub.dev" - source: hosted - version: "14.0.2" - geolocator_android: - dependency: transitive - description: - name: geolocator_android - sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" - url: "https://pub.dev" - source: hosted - version: "5.0.2" - geolocator_apple: - dependency: transitive - description: - name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 - url: "https://pub.dev" - source: hosted - version: "2.3.13" - geolocator_linux: - dependency: transitive - description: - name: geolocator_linux - sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 - url: "https://pub.dev" - source: hosted - version: "0.2.4" - geolocator_platform_interface: - dependency: transitive - description: - name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" - url: "https://pub.dev" - source: hosted - version: "4.2.6" - geolocator_web: - dependency: transitive - description: - name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 - url: "https://pub.dev" - source: hosted - version: "4.1.3" - geolocator_windows: - dependency: transitive - description: - name: geolocator_windows - sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" - url: "https://pub.dev" - source: hosted - version: "0.2.5" - google_maps: - dependency: transitive - description: - name: google_maps - sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" - url: "https://pub.dev" - source: hosted - version: "8.2.0" - google_maps_flutter: - dependency: "direct main" - description: - name: google_maps_flutter - sha256: fc714bf8072e2c121d4277cb6dca23bbfae954b6c7b5d6dd73f1bc8d09762921 - url: "https://pub.dev" - source: hosted - version: "2.17.0" - google_maps_flutter_android: - dependency: transitive - description: - name: google_maps_flutter_android - sha256: f1eb5ffa34ba41f8591e53ce439f78af179a506e8386a1297d0ecd202e05c734 - url: "https://pub.dev" - source: hosted - version: "2.19.8" - google_maps_flutter_ios: - dependency: transitive - description: - name: google_maps_flutter_ios - sha256: "5ed8d8d0f93dfa7f5039c409c500948e98e59068f8f6fcf9105bfd07e3709d7f" - url: "https://pub.dev" - source: hosted - version: "2.18.1" - google_maps_flutter_platform_interface: - dependency: transitive - description: - name: google_maps_flutter_platform_interface - sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 - url: "https://pub.dev" - source: hosted - version: "2.15.0" - google_maps_flutter_web: - dependency: transitive - description: - name: google_maps_flutter_web - sha256: "9b068070bf18b5ec6a7d8ac512c7d557377dbe267658d264d2095b7ee4f1f6c5" - url: "https://pub.dev" - source: hosted - version: "0.6.2+1" - gsettings: - dependency: transitive - description: - name: gsettings - sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" - url: "https://pub.dev" - source: hosted - version: "0.2.8" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - package_info_plus: - dependency: transitive - description: - name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" - url: "https://pub.dev" - source: hosted - version: "9.0.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 - url: "https://pub.dev" - source: hosted - version: "12.0.1" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - sanitize_html: - dependency: transitive - description: - name: sanitize_html - sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 - url: "https://pub.dev" - source: hosted - version: "2.4.23" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - timezone: - dependency: transitive - description: - name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 - url: "https://pub.dev" - source: hosted - version: "0.10.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" - url: "https://pub.dev" - source: hosted - version: "7.0.1" -sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" diff --git a/viewsv1/views/pubspec.yaml b/viewsv1/views/pubspec.yaml deleted file mode 100644 index 520c6e3..0000000 --- a/viewsv1/views/pubspec.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: rutaverde -description: Rastreo del camión de basura en tiempo real - -publish_to: 'none' -version: 1.0.0+1 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - cupertino_icons: ^1.0.6 - google_maps_flutter: ^2.5.0 - geolocator: ^14.0.2 - flutter_local_notifications: ^18.0.1 - firebase_core: ^4.9.0 - firebase_messaging: ^16.2.2 - provider: ^6.1.1 - shared_preferences: ^2.2.2 - http: ^1.1.0 - intl: ^0.20.2 - permission_handler: ^12.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - -flutter: - uses-material-design: true - assets: - - assets/images/