Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>
Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com> Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> modificacion de vistas panel admin, login, animaciones y implementacion de mascota
This commit is contained in:
49
recolecta_app/lib/features/eta/eta_provider.dart
Normal file
49
recolecta_app/lib/features/eta/eta_provider.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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 'package:recolecta_app/features/eta/eta_model.dart';
|
||||
import 'package:recolecta_app/features/eta/eta_service.dart';
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Provider del addressId activo del ciudadano
|
||||
// (se puebla en el provider de auth/session)
|
||||
// ──────────────────────────────────────────
|
||||
class ActiveAddressIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
}
|
||||
|
||||
final activeAddressIdProvider =
|
||||
NotifierProvider<ActiveAddressIdNotifier, String?>(
|
||||
ActiveAddressIdNotifier.new,
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 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,
|
||||
);
|
||||
@@ -1,3 +1,14 @@
|
||||
// lib/features/eta/eta_screen.dart
|
||||
// Vista principal del ciudadano: ETA con mapa de domicilio y progreso de ruta.
|
||||
// Fusiona eta_screen.dart (doc-1) + eta_screen_v2.dart (doc-2).
|
||||
// Orden visual:
|
||||
// 1. Hero card (estado + ventana horaria)
|
||||
// 2. Domicilio registrado
|
||||
// 3. ProgressSteps ← nuevo: justo debajo del mapa/dirección
|
||||
// 4. PreventionBanner
|
||||
// 5. FCM badge
|
||||
// 6. Horario semanal
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -5,43 +16,13 @@ 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';
|
||||
import '../notifications/notification_service.dart';
|
||||
import '../../shared/widgets/prevention_banner.dart';
|
||||
import '../../shared/widgets/progress_steps.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,
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modelo de resultado ETA
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaResult {
|
||||
final String mensaje;
|
||||
final String status;
|
||||
@@ -58,211 +39,446 @@ class _EtaResult {
|
||||
});
|
||||
|
||||
const _EtaResult.noAddress()
|
||||
: mensaje = '',
|
||||
status = '',
|
||||
direccion = '',
|
||||
colonia = '',
|
||||
hasAddress = false;
|
||||
: mensaje = '',
|
||||
status = '',
|
||||
direccion = '',
|
||||
colonia = '',
|
||||
hasAddress = false;
|
||||
|
||||
// ── Utilidades derivadas ───────────────────────────────────────────────────
|
||||
|
||||
bool get isCompleted => status == 'completada';
|
||||
bool get isNearby =>
|
||||
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
|
||||
|
||||
double get progreso {
|
||||
if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) {
|
||||
return 0.85;
|
||||
}
|
||||
if (mensaje.contains('finalizado')) return 1.0;
|
||||
if (isNearby) return 0.85;
|
||||
if (isCompleted) return 1.0;
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
|
||||
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
|
||||
int get stepIndex {
|
||||
if (isCompleted) return 4;
|
||||
if (isNearby) return 3;
|
||||
if (status == 'en_ruta') return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
String get etiquetaEstado {
|
||||
if (status == 'completada') return 'Finalizado';
|
||||
if (isCompleted) return 'Finalizado';
|
||||
if (status == 'en_ruta') return 'En ruta';
|
||||
return 'Pendiente';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pantalla ETA ──────────────────────────────────────────────────────────────
|
||||
class EtaScreen extends ConsumerWidget {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider de ETA
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
||||
@override
|
||||
Future<_EtaResult> build() => _fetch();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(_fetch);
|
||||
}
|
||||
|
||||
Future<_EtaResult> _fetch() 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>(
|
||||
_EtaNotifier.new,
|
||||
);
|
||||
|
||||
// Expone el routeId activo (se puebla desde el provider de sesión/domicilio)
|
||||
class ActiveRouteIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
}
|
||||
|
||||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||||
ActiveRouteIdNotifier.new,
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pantalla principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class EtaScreen extends ConsumerStatefulWidget {
|
||||
const EtaScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<EtaScreen> createState() => _EtaScreenState();
|
||||
}
|
||||
|
||||
class _EtaScreenState extends ConsumerState<EtaScreen>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
|
||||
NotificationService.onFcmMessage.addListener(_onPush);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
NotificationService.onFcmMessage.removeListener(_onPush);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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: AppTheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Estado del camión'),
|
||||
title: const Text('Mi recolección'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Actualizar',
|
||||
onPressed: () => ref.invalidate(etaProvider),
|
||||
onPressed: () => ref.read(etaProvider.notifier).refresh(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: etaAsync.when(
|
||||
loading: () => const _EtaLoading(),
|
||||
error: (error, _) => _EtaError(
|
||||
error: error.toString(),
|
||||
onRetry: () => ref.invalidate(etaProvider),
|
||||
error: (e, _) => _EtaError(
|
||||
error: e.toString(),
|
||||
onRetry: () => ref.read(etaProvider.notifier).refresh(),
|
||||
),
|
||||
data: (result) => result.hasAddress
|
||||
? _EtaContent(result: result)
|
||||
: _NoAddressState(
|
||||
onAdd: () => context.go('/addresses/new'),
|
||||
),
|
||||
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contenido ETA ─────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Contenido principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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,
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ProviderScope.containerOf(
|
||||
context,
|
||||
).read(etaProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
children: [
|
||||
// ── 1. Hero card ────────────────────────────────────────────────
|
||||
_EtaHeroCard(result: result),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 2. Domicilio registrado ─────────────────────────────────────
|
||||
AppInfoRow(
|
||||
icon: Icons.home_outlined,
|
||||
label: 'Col. ${result.colonia}',
|
||||
value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion,
|
||||
trailing: AppStatusBadge.green('Activo'),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
|
||||
ProgressSteps(stepIndex: result.stepIndex),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 4. Banner de prevención ─────────────────────────────────────
|
||||
const PreventionBanner(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 5. Badge de suscripción FCM ─────────────────────────────────
|
||||
const _FcmStatusBadge(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 6. Horario semanal ──────────────────────────────────────────
|
||||
AppSectionTitle(title: 'Horario del camión'),
|
||||
_HorarioCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Hero card: estado + ventana horaria + barra de progreso
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaHeroCard extends StatelessWidget {
|
||||
final _EtaResult result;
|
||||
const _EtaHeroCard({required this.result});
|
||||
|
||||
Color _bgColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
if (result.isCompleted) return cs.surfaceContainerHighest;
|
||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||||
return const Color(0xFFE1F5EE); // teal-50
|
||||
}
|
||||
|
||||
Color _accentColor(BuildContext context) {
|
||||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||
if (result.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(20),
|
||||
decoration: BoxDecoration(
|
||||
color: _bgColor(context),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: accent.withOpacity(0.3)),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Cabecera: icono + etiqueta + punto vivo
|
||||
Row(
|
||||
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,
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
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),
|
||||
const SizedBox(width: 12),
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Camión recolector',
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_StatusPill(result: result, accent: accent),
|
||||
],
|
||||
),
|
||||
),
|
||||
_LiveDot(active: result.status == 'en_ruta'),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Ventana horaria o mensaje de estado
|
||||
Text(
|
||||
result.mensaje.isNotEmpty
|
||||
? result.mensaje
|
||||
: _windowLabel(result.status),
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accent,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Barra de progreso
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: result.progreso,
|
||||
backgroundColor: accent.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(accent),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Inicio de ruta',
|
||||
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Tu casa',
|
||||
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _windowLabel(String s) {
|
||||
switch (s) {
|
||||
case 'completada':
|
||||
return 'Servicio finalizado';
|
||||
case 'diferida':
|
||||
return 'Servicio diferido';
|
||||
case 'reasignada':
|
||||
return 'Ruta reasignada';
|
||||
default:
|
||||
return 'En camino';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pill de estado con punto pulsante
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _StatusPill extends StatelessWidget {
|
||||
final _EtaResult result;
|
||||
final Color accent;
|
||||
const _StatusPill({required this.result, required this.accent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = result.isNearby
|
||||
? 'Cerca de tu domicilio'
|
||||
: result.isCompleted
|
||||
? 'Servicio completado'
|
||||
: 'En camino a tu sector';
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!result.isCompleted) _PulsingDot(color: accent),
|
||||
if (!result.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Horario estimado de la semana ──────────────────────────────
|
||||
AppSectionTitle(title: 'Horario del camión'),
|
||||
_HorarioCard(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Punto animado "en vivo" ───────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Punto pulsante (animación de opacidad)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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 final AnimationController _ctrl;
|
||||
late final 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Punto vivo "EN VIVO" (escala + opacidad)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _LiveDot extends StatefulWidget {
|
||||
final bool active;
|
||||
const _LiveDot({required this.active});
|
||||
@@ -292,34 +508,87 @@ class _LiveDotState extends State<_LiveDot>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.active) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (!widget.active) return const SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, child) => Container(
|
||||
builder: (_, __) => Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppTheme.primary
|
||||
.withValues(alpha: 0.5 + _anim.value * 0.5),
|
||||
color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Horario ───────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Horario semanal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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),
|
||||
static const _dias = [
|
||||
_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
|
||||
@@ -369,11 +638,16 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sin domicilio ─────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sin domicilio registrado
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _NoAddressState extends StatelessWidget {
|
||||
final VoidCallback onAdd;
|
||||
const _NoAddressState({required this.onAdd});
|
||||
@@ -393,23 +667,30 @@ class _NoAddressState extends StatelessWidget {
|
||||
color: AppTheme.primaryLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.home_outlined,
|
||||
color: AppTheme.primary, size: 40),
|
||||
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),
|
||||
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),
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
@@ -426,7 +707,9 @@ class _NoAddressState extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cargando ──────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cargando
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaLoading extends StatelessWidget {
|
||||
const _EtaLoading();
|
||||
|
||||
@@ -434,19 +717,23 @@ class _EtaLoading extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: AppTheme.primary),
|
||||
SizedBox(height: 16),
|
||||
Text('Consultando estado del camión…',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)),
|
||||
Text(
|
||||
'Consultando estado del servicio...',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaError extends StatelessWidget {
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
@@ -460,25 +747,36 @@ class _EtaError extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_outlined,
|
||||
color: AppTheme.textSecondary, size: 48),
|
||||
const Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
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 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)),
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: ElevatedButton(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Reintentar'),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Reintentar'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
25
recolecta_app/lib/features/eta/eta_service.dart
Normal file
25
recolecta_app/lib/features/eta/eta_service.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 'package:recolecta_app/core/network/api_client.dart';
|
||||
import 'package:recolecta_app/features/eta/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(apiClientProvider)),
|
||||
);
|
||||
Reference in New Issue
Block a user