mascota en login,

This commit is contained in:
shinra32
2026-05-23 04:30:48 -06:00
parent 68d04f3917
commit 89dcc6250b
14 changed files with 239 additions and 798 deletions

View File

@@ -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<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends State<AdminScreen>
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<String>(
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<TruckStatus>(
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'),
),
],
),
),
);
}
}

View File

@@ -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),
);
},
),
),
);
}

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -25,7 +25,10 @@ class _MainShellState extends State<MainShell> {
@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),

View File

@@ -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',

View File

@@ -13,26 +13,45 @@ class ChatMessage {
Map<String, dynamic> toJson() => {'role': role, 'content': content};
}
class AiChatNotifier extends StateNotifier<List<ChatMessage>> {
AiChatNotifier()
: super([
// Estado inmutable para el chat
class ChatState {
final List<ChatMessage> messages;
final bool isLoading;
ChatState({required this.messages, this.isLoading = false});
ChatState copyWith({List<ChatMessage>? messages, bool? isLoading}) {
return ChatState(
messages: messages ?? this.messages,
isLoading: isLoading ?? this.isLoading,
);
}
}
class AiChatNotifier extends Notifier<ChatState> {
@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<void> 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<List<ChatMessage>> {
'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<List<ChatMessage>> {
);
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<AiChatNotifier, List<ChatMessage>>(
(ref) {
return AiChatNotifier();
},
final aiChatProvider = NotifierProvider<AiChatNotifier, ChatState>(
AiChatNotifier.new,
);

View File

@@ -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<AiPetChatScreen> {
@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<AiPetChatScreen> {
),
),
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',