mascota en login,
This commit is contained in:
@@ -3,8 +3,6 @@ plugins {
|
|||||||
// START: FlutterFire Configuration
|
// START: FlutterFire Configuration
|
||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
// END: FlutterFire Configuration
|
// 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")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +14,7 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -42,6 +37,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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
|
||||||
|
|||||||
BIN
recolecta_app/assets/animations/blink_saludo.gif
Normal file
BIN
recolecta_app/assets/animations/blink_saludo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
recolecta_app/assets/animations/info.gif
Normal file
BIN
recolecta_app/assets/animations/info.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 801 KiB |
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/auth/login_page.dart';
|
||||||
import 'package:recolecta_app/features/splash/splash_screen.dart';
|
import 'package:recolecta_app/features/splash/splash_screen.dart';
|
||||||
import 'package:recolecta_app/features/auth/register_page.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/features/separation_guide/screens/separation_guide_screen.dart';
|
||||||
import 'package:recolecta_app/core/services/auth_controller.dart';
|
import 'package:recolecta_app/core/services/auth_controller.dart';
|
||||||
import '../../features/addresses/add_address_page.dart';
|
import '../../features/addresses/add_address_page.dart';
|
||||||
import '../../features/admin/screens/admin_dashboard_screen.dart';
|
|
||||||
import '../../features/notifications/notifications_screen.dart';
|
import '../../features/notifications/notifications_screen.dart';
|
||||||
import '../../features/quiz/quiz_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<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authControllerProvider);
|
final authState = ref.watch(authControllerProvider);
|
||||||
|
|
||||||
@@ -89,29 +72,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── Admin ─────────────────────────────────────────────────────────────
|
// ── Admin ─────────────────────────────────────────────────────────────
|
||||||
ShellRoute(
|
GoRoute(path: '/admin', builder: (context, state) => const AdminScreen()),
|
||||||
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']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Chofer ────────────────────────────────────────────────────────────
|
// ── Chofer ────────────────────────────────────────────────────────────
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
|
|||||||
@@ -1078,706 +1078,5 @@ void _confirmAndDelete(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy stubs (no longer used; kept enum to avoid breaking imports) ────────
|
// EOF
|
||||||
enum _LegacyTruckStatus { 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<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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class VideoMascot extends StatelessWidget {
|
class VideoMascot extends StatelessWidget {
|
||||||
final double size;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -16,6 +17,8 @@ class VideoMascot extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
// Cargamos el archivo como GIF
|
// Cargamos el archivo como GIF
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: zoom,
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/animations/blink_saludo.gif',
|
'assets/animations/blink_saludo.gif',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -26,6 +29,7 @@ class VideoMascot extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 '../../core/theme/app_theme.dart';
|
||||||
|
import '../home/colonias_data.dart';
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../notifications/notification_service.dart';
|
import '../notifications/notification_service.dart';
|
||||||
import '../../shared/widgets/prevention_banner.dart';
|
import '../../shared/widgets/prevention_banner.dart';
|
||||||
import '../../shared/widgets/progress_steps.dart';
|
import '../../shared/widgets/progress_steps.dart';
|
||||||
|
import '../separation_guide/ai_pet_chat_screen.dart';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Modelo de resultado ETA
|
// Modelo de resultado ETA
|
||||||
@@ -29,6 +33,8 @@ class _EtaResult {
|
|||||||
final String direccion;
|
final String direccion;
|
||||||
final String colonia;
|
final String colonia;
|
||||||
final bool hasAddress;
|
final bool hasAddress;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
|
||||||
const _EtaResult({
|
const _EtaResult({
|
||||||
required this.mensaje,
|
required this.mensaje,
|
||||||
@@ -36,6 +42,8 @@ class _EtaResult {
|
|||||||
required this.direccion,
|
required this.direccion,
|
||||||
required this.colonia,
|
required this.colonia,
|
||||||
required this.hasAddress,
|
required this.hasAddress,
|
||||||
|
this.lat,
|
||||||
|
this.lng,
|
||||||
});
|
});
|
||||||
|
|
||||||
const _EtaResult.noAddress()
|
const _EtaResult.noAddress()
|
||||||
@@ -43,7 +51,9 @@ class _EtaResult {
|
|||||||
status = '',
|
status = '',
|
||||||
direccion = '',
|
direccion = '',
|
||||||
colonia = '',
|
colonia = '',
|
||||||
hasAddress = false;
|
hasAddress = false,
|
||||||
|
lat = null,
|
||||||
|
lng = null;
|
||||||
|
|
||||||
// ── Utilidades derivadas ───────────────────────────────────────────────────
|
// ── Utilidades derivadas ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -114,12 +124,14 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
|||||||
status: data['status'] as String? ?? '',
|
status: data['status'] as String? ?? '',
|
||||||
direccion: items.first['calle'] as String? ?? '',
|
direccion: items.first['calle'] as String? ?? '',
|
||||||
colonia: items.first['colonia'] as String? ?? '',
|
colonia: items.first['colonia'] as String? ?? '',
|
||||||
|
lat: (items.first['lat'] as num?)?.toDouble(),
|
||||||
|
lng: (items.first['lng'] as num?)?.toDouble(),
|
||||||
hasAddress: true,
|
hasAddress: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>(
|
final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
|
||||||
_EtaNotifier.new,
|
_EtaNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,7 +239,13 @@ class _EtaContent extends StatelessWidget {
|
|||||||
trailing: AppStatusBadge.green('Activo'),
|
trailing: AppStatusBadge.green('Activo'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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) ───────────
|
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
|
||||||
ProgressSteps(stepIndex: result.stepIndex),
|
ProgressSteps(stepIndex: result.stepIndex),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -236,11 +254,15 @@ class _EtaContent extends StatelessWidget {
|
|||||||
const PreventionBanner(),
|
const PreventionBanner(),
|
||||||
const SizedBox(height: 12),
|
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 _FcmStatusBadge(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── 6. Horario semanal ──────────────────────────────────────────
|
// ── 7. Horario semanal ──────────────────────────────────────────
|
||||||
AppSectionTitle(title: 'Horario del camión'),
|
AppSectionTitle(title: 'Horario del camión'),
|
||||||
_HorarioCard(),
|
_HorarioCard(),
|
||||||
const SizedBox(height: 24),
|
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
|
// Hero card: estado + ventana horaria + barra de progreso
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ class _MainShellState extends State<MainShell> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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(
|
bottomNavigationBar: AppBottomNav(
|
||||||
currentIndex: _currentIndex,
|
currentIndex: _currentIndex,
|
||||||
onTap: (i) => setState(() => _currentIndex = i),
|
onTap: (i) => setState(() => _currentIndex = i),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../core/widgets/app_widgets.dart';
|
|||||||
import '../../core/services/auth_controller.dart';
|
import '../../core/services/auth_controller.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
import '../../core/constants/auth_constants.dart';
|
import '../../core/constants/auth_constants.dart';
|
||||||
|
import '../separation_guide/ai_pet_chat_screen.dart';
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
@@ -78,6 +79,17 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const AppSectionTitle(title: 'Soporte'),
|
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(
|
AppMenuTile(
|
||||||
icon: Icons.help_outline,
|
icon: Icons.help_outline,
|
||||||
title: 'Ayuda y preguntas frecuentes',
|
title: 'Ayuda y preguntas frecuentes',
|
||||||
|
|||||||
@@ -13,26 +13,45 @@ class ChatMessage {
|
|||||||
Map<String, dynamic> toJson() => {'role': role, 'content': content};
|
Map<String, dynamic> toJson() => {'role': role, 'content': content};
|
||||||
}
|
}
|
||||||
|
|
||||||
class AiChatNotifier extends StateNotifier<List<ChatMessage>> {
|
// Estado inmutable para el chat
|
||||||
AiChatNotifier()
|
class ChatState {
|
||||||
: super([
|
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(
|
ChatMessage(
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content:
|
content:
|
||||||
'¡Hola! Soy Eco 🍃, la mascota de Recolecta. '
|
'¡Hola! Soy Eco 🍃, la mascota de Recolecta. '
|
||||||
'Estoy aquí para ayudarte a reciclar y separar tu basura correctamente. ¿Tienes alguna duda?',
|
'Estoy aquí para ayudarte a reciclar y separar tu basura correctamente. ¿Tienes alguna duda?',
|
||||||
),
|
),
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
bool isLoading = false;
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String userText) async {
|
Future<void> sendMessage(String userText) async {
|
||||||
if (userText.trim().isEmpty) return;
|
if (userText.trim().isEmpty) return;
|
||||||
|
|
||||||
// Añadir mensaje del usuario
|
// Añadir mensaje del usuario
|
||||||
final userMsg = ChatMessage(role: 'user', content: userText);
|
final userMsg = ChatMessage(role: 'user', content: userText);
|
||||||
state = [...state, userMsg];
|
state = state.copyWith(
|
||||||
isLoading = true;
|
messages: [...state.messages, userMsg],
|
||||||
|
isLoading: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dio = Dio();
|
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.',
|
'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(
|
final response = await dio.post(
|
||||||
'https://api.openai.com/v1/chat/completions',
|
'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'];
|
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) {
|
} catch (e) {
|
||||||
debugPrint('Error en OpenAI: $e');
|
debugPrint('Error en OpenAI: $e');
|
||||||
state = [
|
state = state.copyWith(
|
||||||
...state,
|
messages: [
|
||||||
|
...state.messages,
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content:
|
content:
|
||||||
'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?',
|
'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?',
|
||||||
),
|
),
|
||||||
];
|
],
|
||||||
} finally {
|
isLoading: false,
|
||||||
isLoading = false;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final aiChatProvider = StateNotifierProvider<AiChatNotifier, List<ChatMessage>>(
|
final aiChatProvider = NotifierProvider<AiChatNotifier, ChatState>(
|
||||||
(ref) {
|
AiChatNotifier.new,
|
||||||
return AiChatNotifier();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../core/theme/app_theme.dart';
|
||||||
|
import '../auth/widgets/video_mascot.dart';
|
||||||
import 'ai_chat_provider.dart';
|
import 'ai_chat_provider.dart';
|
||||||
|
|
||||||
class AiPetChatScreen extends ConsumerStatefulWidget {
|
class AiPetChatScreen extends ConsumerStatefulWidget {
|
||||||
@@ -54,10 +53,9 @@ class _AiPetChatScreenState extends ConsumerState<AiPetChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final messages = ref.watch(aiChatProvider);
|
final chatState = ref.watch(aiChatProvider);
|
||||||
// No podemos leer isLoading directamente de ref.watch(provider) porque es StateNotifierProvider.
|
final messages = chatState.messages;
|
||||||
// Para leer la variable, leemos el notifier.
|
final isLoading = chatState.isLoading;
|
||||||
final isLoading = ref.watch(aiChatProvider.notifier).isLoading;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
@@ -80,12 +78,10 @@ class _AiPetChatScreenState extends ConsumerState<AiPetChatScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
// Reemplaza este Icono con tu animación de Lottie:
|
|
||||||
// child: Lottie.asset('assets/animations/mascota_feliz.json', height: 120),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.pets, size: 64, color: AppTheme.primary),
|
const VideoMascot(size: 80),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
isLoading ? 'Eco está pensando...' : 'Eco te escucha',
|
isLoading ? 'Eco está pensando...' : 'Eco te escucha',
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ flutter:
|
|||||||
# - assets/images/
|
# - assets/images/
|
||||||
- assets/.env
|
- assets/.env
|
||||||
- assets/data/separation_guide.json
|
- assets/data/separation_guide.json
|
||||||
|
- assets/animations/blink_saludo.gif
|
||||||
- assets/animations/
|
- assets/animations/
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
# included with your application, so that you can use the icons in
|
# included with your application, so that you can use the icons in
|
||||||
|
|||||||
Reference in New Issue
Block a user