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
490 lines
16 KiB
Dart
490 lines
16 KiB
Dart
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'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|