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