diff --git a/recolecta_app/android/app/build.gradle.kts b/recolecta_app/android/app/build.gradle.kts index 205c0b2..071c6bb 100644 --- a/recolecta_app/android/app/build.gradle.kts +++ b/recolecta_app/android/app/build.gradle.kts @@ -3,8 +3,6 @@ plugins { // START: FlutterFire Configuration id("com.google.gms.google-services") // END: FlutterFire Configuration - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } @@ -16,10 +14,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + isCoreLibraryDesugaringEnabled = true } defaultConfig { @@ -42,6 +37,10 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} + flutter { source = "../.." } diff --git a/recolecta_app/android/gradle.properties b/recolecta_app/android/gradle.properties index fbee1d8..d5da727 100644 --- a/recolecta_app/android/gradle.properties +++ b/recolecta_app/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/recolecta_app/assets/animations/blink_saludo.gif b/recolecta_app/assets/animations/blink_saludo.gif new file mode 100644 index 0000000..cd6fcd3 Binary files /dev/null and b/recolecta_app/assets/animations/blink_saludo.gif differ diff --git a/recolecta_app/assets/animations/info.gif b/recolecta_app/assets/animations/info.gif new file mode 100644 index 0000000..02db177 Binary files /dev/null and b/recolecta_app/assets/animations/info.gif differ diff --git a/recolecta_app/assets/animations/saludo.mp4 b/recolecta_app/assets/animations/saludo.mp4 deleted file mode 100644 index a70625e..0000000 Binary files a/recolecta_app/assets/animations/saludo.mp4 and /dev/null differ diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart index ee9fc85..f62b8c6 100644 --- a/recolecta_app/lib/core/router/app_router.dart +++ b/recolecta_app/lib/core/router/app_router.dart @@ -1,7 +1,7 @@ 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/admin/admin_screen.dart'; import 'package:recolecta_app/features/auth/login_page.dart'; import 'package:recolecta_app/features/splash/splash_screen.dart'; import 'package:recolecta_app/features/auth/register_page.dart'; @@ -20,26 +20,9 @@ import 'package:recolecta_app/features/separation_guide/screens/category_detail_ import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart'; import 'package:recolecta_app/core/services/auth_controller.dart'; import '../../features/addresses/add_address_page.dart'; -import '../../features/admin/screens/admin_dashboard_screen.dart'; import '../../features/notifications/notifications_screen.dart'; import '../../features/quiz/quiz_screen.dart'; -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); @@ -89,29 +72,7 @@ final routerProvider = Provider((ref) { ), // ── Admin ───────────────────────────────────────────────────────────── - 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']!, - ), - ), - ], - ), - ], - ), + GoRoute(path: '/admin', builder: (context, state) => const AdminScreen()), // ── Chofer ──────────────────────────────────────────────────────────── ShellRoute( diff --git a/recolecta_app/lib/features/admin/admin_screen.dart b/recolecta_app/lib/features/admin/admin_screen.dart index 8824d56..ae23389 100644 --- a/recolecta_app/lib/features/admin/admin_screen.dart +++ b/recolecta_app/lib/features/admin/admin_screen.dart @@ -1078,706 +1078,5 @@ void _confirmAndDelete( ); } -// ── Legacy stubs (no longer used; kept enum to avoid breaking imports) ──────── -enum _LegacyTruckStatus { disponible, enRuta, mantenimiento, detenido } +// EOF -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/auth/widgets/video_mascot.dart b/recolecta_app/lib/features/auth/widgets/video_mascot.dart index bd43d42..0b40e5c 100644 --- a/recolecta_app/lib/features/auth/widgets/video_mascot.dart +++ b/recolecta_app/lib/features/auth/widgets/video_mascot.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; class VideoMascot extends StatelessWidget { final double size; + final double zoom; - const VideoMascot({super.key, this.size = 108}); + const VideoMascot({super.key, this.size = 108, this.zoom = 5.5}); @override Widget build(BuildContext context) { @@ -16,15 +17,18 @@ class VideoMascot extends StatelessWidget { ), clipBehavior: Clip.hardEdge, // Cargamos el archivo como GIF - child: Image.asset( - 'assets/animations/blink_saludo.gif', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - // Plan B: si el archivo no existe o hay error, mostramos la huellita - return const Center( - child: Icon(Icons.pets, color: Colors.white, size: 48), - ); - }, + child: Transform.scale( + scale: zoom, + child: Image.asset( + 'assets/animations/blink_saludo.gif', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Plan B: si el archivo no existe o hay error, mostramos la huellita + return const Center( + child: Icon(Icons.pets, color: Colors.white, size: 48), + ); + }, + ), ), ); } diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart index 6e5e3a4..34bb2e8 100644 --- a/recolecta_app/lib/features/eta/eta_screen.dart +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -12,13 +12,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; import '../../core/theme/app_theme.dart'; +import '../home/colonias_data.dart'; import '../../core/widgets/app_widgets.dart'; import '../../core/network/api_client.dart'; import '../notifications/notification_service.dart'; import '../../shared/widgets/prevention_banner.dart'; import '../../shared/widgets/progress_steps.dart'; +import '../separation_guide/ai_pet_chat_screen.dart'; // ───────────────────────────────────────────────────────────────────────────── // Modelo de resultado ETA @@ -29,6 +33,8 @@ class _EtaResult { final String direccion; final String colonia; final bool hasAddress; + final double? lat; + final double? lng; const _EtaResult({ required this.mensaje, @@ -36,6 +42,8 @@ class _EtaResult { required this.direccion, required this.colonia, required this.hasAddress, + this.lat, + this.lng, }); const _EtaResult.noAddress() @@ -43,7 +51,9 @@ class _EtaResult { status = '', direccion = '', colonia = '', - hasAddress = false; + hasAddress = false, + lat = null, + lng = null; // ── Utilidades derivadas ─────────────────────────────────────────────────── @@ -114,12 +124,14 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> { status: data['status'] as String? ?? '', direccion: items.first['calle'] as String? ?? '', colonia: items.first['colonia'] as String? ?? '', + lat: (items.first['lat'] as num?)?.toDouble(), + lng: (items.first['lng'] as num?)?.toDouble(), hasAddress: true, ); } } -final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>( +final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>( _EtaNotifier.new, ); @@ -227,7 +239,13 @@ class _EtaContent extends StatelessWidget { trailing: AppStatusBadge.green('Activo'), ), const SizedBox(height: 12), - + // ── 2.5. Mapa de ubicación ───────────────────────────────── + _MapaUbicacion( + colonia: result.colonia, + lat: result.lat, + lng: result.lng, + ), + const SizedBox(height: 12), // ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── ProgressSteps(stepIndex: result.stepIndex), const SizedBox(height: 12), @@ -236,11 +254,15 @@ class _EtaContent extends StatelessWidget { const PreventionBanner(), const SizedBox(height: 12), - // ── 5. Badge de suscripción FCM ───────────────────────────────── + // ── 5. Banner del Chat IA (Eco) ───────────────────────────────── + const _EcoChatBanner(), + const SizedBox(height: 12), + + // ── 6. Badge de suscripción FCM ───────────────────────────────── const _FcmStatusBadge(), const SizedBox(height: 16), - // ── 6. Horario semanal ────────────────────────────────────────── + // ── 7. Horario semanal ────────────────────────────────────────── AppSectionTitle(title: 'Horario del camión'), _HorarioCard(), const SizedBox(height: 24), @@ -250,6 +272,122 @@ class _EtaContent extends StatelessWidget { } } +// ───────────────────────────────────────────────────────────────────────────── +// Banner de Eco (Chat IA) +// ───────────────────────────────────────────────────────────────────────────── +class _EcoChatBanner extends StatelessWidget { + const _EcoChatBanner(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiPetChatScreen()), + ); + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.primaryDark, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.white24, + shape: BoxShape.circle, + ), + child: const Icon(Icons.pets, color: Colors.white, size: 28), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '¿Dudas sobre reciclaje?', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4), + Text( + 'Pregúntale a Eco, tu asistente inteligente', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Colors.white), + ], + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mapa de ubicación del domicilio (no interactivo) +// ───────────────────────────────────────────────────────────────────────────── +class _MapaUbicacion extends StatelessWidget { + final String colonia; + final double? lat; + final double? lng; + const _MapaUbicacion({required this.colonia, this.lat, this.lng}); + + @override + Widget build(BuildContext context) { + // Usar coordenadas del usuario si están disponibles, sino usar centro de colonia + final center = kColoniaCenter(colonia); + final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center; + + 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( + initialCenter: pin, + initialZoom: 16.0, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.none, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + MarkerLayer( + markers: [ + Marker( + point: pin, + width: 36, + height: 36, + child: const Icon( + Icons.home_rounded, + color: AppTheme.primary, + size: 36, + ), + ), + ], + ), + ], + ), + ); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Hero card: estado + ventana horaria + barra de progreso // ───────────────────────────────────────────────────────────────────────────── diff --git a/recolecta_app/lib/features/home/main_shell.dart b/recolecta_app/lib/features/home/main_shell.dart index e8c9631..0b6e3f3 100644 --- a/recolecta_app/lib/features/home/main_shell.dart +++ b/recolecta_app/lib/features/home/main_shell.dart @@ -25,7 +25,10 @@ class _MainShellState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: IndexedStack(index: _currentIndex, children: _screens), + // Renderiza únicamente la pantalla activa para desmontar vistas nativas + // (p. ej. FlutterMap) cuando la pestaña no está activa, evitando que + // queden superpuestas sobre la UI de otras pestañas. + body: _screens[_currentIndex], 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 index 611db4b..9fcc75b 100644 --- a/recolecta_app/lib/features/profile/profile_screen.dart +++ b/recolecta_app/lib/features/profile/profile_screen.dart @@ -7,6 +7,7 @@ import '../../core/widgets/app_widgets.dart'; import '../../core/services/auth_controller.dart'; import '../../core/storage/secure_storage.dart'; import '../../core/constants/auth_constants.dart'; +import '../separation_guide/ai_pet_chat_screen.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @@ -78,6 +79,17 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), const AppSectionTitle(title: 'Soporte'), + AppMenuTile( + icon: Icons.pets, + title: 'Hablar con Eco (Asistente IA)', + subtitle: 'Guía de separación de residuos', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiPetChatScreen()), + ); + }, + ), AppMenuTile( icon: Icons.help_outline, title: 'Ayuda y preguntas frecuentes', diff --git a/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart index b833ea9..9bf563d 100644 --- a/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart +++ b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart @@ -13,26 +13,45 @@ class ChatMessage { Map toJson() => {'role': role, 'content': content}; } -class AiChatNotifier extends StateNotifier> { - AiChatNotifier() - : super([ +// Estado inmutable para el chat +class ChatState { + final List messages; + final bool isLoading; + + ChatState({required this.messages, this.isLoading = false}); + + ChatState copyWith({List? messages, bool? isLoading}) { + return ChatState( + messages: messages ?? this.messages, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +class AiChatNotifier extends Notifier { + @override + ChatState build() { + return ChatState( + messages: [ ChatMessage( role: 'assistant', content: '¡Hola! Soy Eco 🍃, la mascota de Recolecta. ' 'Estoy aquí para ayudarte a reciclar y separar tu basura correctamente. ¿Tienes alguna duda?', ), - ]); - - bool isLoading = false; + ], + ); + } Future sendMessage(String userText) async { if (userText.trim().isEmpty) return; // Añadir mensaje del usuario final userMsg = ChatMessage(role: 'user', content: userText); - state = [...state, userMsg]; - isLoading = true; + state = state.copyWith( + messages: [...state.messages, userMsg], + isLoading: true, + ); try { final dio = Dio(); @@ -53,7 +72,7 @@ class AiChatNotifier extends StateNotifier> { 'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.', ); - final messagesForApi = [systemPrompt, ...state]; + final messagesForApi = [systemPrompt, ...state.messages]; final response = await dio.post( 'https://api.openai.com/v1/chat/completions', @@ -72,25 +91,30 @@ class AiChatNotifier extends StateNotifier> { ); final botReply = response.data['choices'][0]['message']['content']; - state = [...state, ChatMessage(role: 'assistant', content: botReply)]; + state = state.copyWith( + messages: [ + ...state.messages, + ChatMessage(role: 'assistant', content: botReply), + ], + isLoading: false, + ); } catch (e) { debugPrint('Error en OpenAI: $e'); - state = [ - ...state, - ChatMessage( - role: 'assistant', - content: - 'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?', - ), - ]; - } finally { - isLoading = false; + state = state.copyWith( + messages: [ + ...state.messages, + ChatMessage( + role: 'assistant', + content: + 'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?', + ), + ], + isLoading: false, + ); } } } -final aiChatProvider = StateNotifierProvider>( - (ref) { - return AiChatNotifier(); - }, +final aiChatProvider = NotifierProvider( + AiChatNotifier.new, ); diff --git a/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart b/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart index b7bde5a..8988d15 100644 --- a/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart +++ b/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -// Importa Lottie si tus animaciones están en formato Lottie (.json) -// import 'package:lottie/lottie.dart'; import '../../core/theme/app_theme.dart'; +import '../auth/widgets/video_mascot.dart'; import 'ai_chat_provider.dart'; class AiPetChatScreen extends ConsumerStatefulWidget { @@ -54,10 +53,9 @@ class _AiPetChatScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final messages = ref.watch(aiChatProvider); - // No podemos leer isLoading directamente de ref.watch(provider) porque es StateNotifierProvider. - // Para leer la variable, leemos el notifier. - final isLoading = ref.watch(aiChatProvider.notifier).isLoading; + final chatState = ref.watch(aiChatProvider); + final messages = chatState.messages; + final isLoading = chatState.isLoading; return Scaffold( backgroundColor: AppTheme.background, @@ -80,12 +78,10 @@ class _AiPetChatScreenState extends ConsumerState { ), ), child: Center( - // Reemplaza este Icono con tu animación de Lottie: - // child: Lottie.asset('assets/animations/mascota_feliz.json', height: 120), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.pets, size: 64, color: AppTheme.primary), + const VideoMascot(size: 80), const SizedBox(height: 8), Text( isLoading ? 'Eco está pensando...' : 'Eco te escucha', diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index 746dd32..d6197e9 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -67,6 +67,7 @@ flutter: # - assets/images/ - assets/.env - assets/data/separation_guide.json + - assets/animations/blink_saludo.gif - assets/animations/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in