import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:table_calendar/table_calendar.dart'; import '../models/address_entry.dart'; import '../models/colony_route.dart'; import '../models/calendar_event_entry.dart'; import '../models/address_record.dart'; import '../models/auth_session.dart'; import '../models/route_guide_entry.dart'; import '../models/route_notification.dart'; import '../models/truck_route.dart'; import '../services/address_repository.dart'; import '../services/auth_repository.dart'; import '../services/local_seed_repository.dart'; import 'auth_screen.dart'; String formatScheduleAsAmPm(String schedule) { final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); if (segments.length != 2) { return schedule; } TimeOfDay? parseSegment(String value) { final parts = value.split(':'); if (parts.length != 2) { return null; } final hour = int.tryParse(parts[0].trim()); final minute = int.tryParse(parts[1].trim()); if (hour == null || minute == null) { return null; } return TimeOfDay(hour: hour, minute: minute); } String formatTime(TimeOfDay timeOfDay) { final hour = timeOfDay.hourOfPeriod == 0 ? 12 : timeOfDay.hourOfPeriod; final minute = timeOfDay.minute.toString().padLeft(2, '0'); final suffix = timeOfDay.period == DayPeriod.am ? 'AM' : 'PM'; return '$hour:$minute $suffix'; } final start = parseSegment(segments[0]); final end = parseSegment(segments[1]); if (start == null || end == null) { return schedule; } return '${formatTime(start)} - ${formatTime(end)}'; } String guideCountdownText(RouteGuideEntry guide, DateTime now) { final weekdays = guide.days.map((day) { switch (day.trim().toLowerCase()) { case 'lunes': return DateTime.monday; case 'martes': return DateTime.tuesday; case 'miercoles': case 'miércoles': return DateTime.wednesday; case 'jueves': return DateTime.thursday; case 'viernes': return DateTime.friday; case 'sabado': case 'sábado': return DateTime.saturday; case 'domingo': return DateTime.sunday; default: return null; } }).whereType().toList(growable: false); if (weekdays.isEmpty) { return 'Sin horario definido'; } DateTime? parseStart(DateTime date) { final parts = guide.schedule.split('-').first.trim().split(':'); if (parts.length != 2) { return null; } final hour = int.tryParse(parts[0].trim()); final minute = int.tryParse(parts[1].trim()); if (hour == null || minute == null) { return null; } return DateTime(date.year, date.month, date.day, hour, minute); } for (var offset = 0; offset < 8; offset++) { final candidateDate = now.add(Duration(days: offset)); if (!weekdays.contains(candidateDate.weekday)) { continue; } final start = parseStart(candidateDate); if (start == null) { return 'Sin horario definido'; } if (start.isAfter(now)) { return 'Faltan ${formatDuration(start.difference(now))}'; } if (offset == 0) { return 'Llega ahora'; } } return 'Próxima llegada en breve'; } String formatDuration(Duration duration) { final totalMinutes = duration.inMinutes; if (totalMinutes <= 0) { return '0 min'; } final hours = totalMinutes ~/ 60; final minutes = totalMinutes % 60; if (hours > 0) { return minutes > 0 ? '$hours h $minutes min' : '$hours h'; } return '$minutes min'; } class DashboardScreen extends StatefulWidget { const DashboardScreen({ super.key, required this.authRepository, required this.addressRepository, required this.session, this.savedAddress, this.enableLiveFeatures = true, }); final AuthRepository authRepository; final AddressRepository addressRepository; final AuthSession session; final AddressEntry? savedAddress; final bool enableLiveFeatures; @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { final MapController _mapController = MapController(); final TextEditingController _calendarNoteController = TextEditingController(); final TextEditingController _newHouseNumberController = TextEditingController(); final TextEditingController _newColoniaController = TextEditingController(); final TextEditingController _newStreetController = TextEditingController(); final List<_AppNotification> _notifications = <_AppNotification>[]; final Map _calendarNotes = {}; final Random _random = Random(); int _selectedIndex = 0; LatLng _center = const LatLng(19.4326, -99.1332); bool _isLoadingLocation = true; bool _showLiveFeatures = true; String? _locationError; Timer? _truckTimer; LatLng? _truckPosition; bool _truckVisible = false; LocalSeedData? _seedData; TruckRoute? _activeRoute; ColonyRoute? _selectedColonyRoute; int _routePlaybackIndex = 0; bool _loadingAddresses = true; String? _addressesError; List _addresses = []; String? _selectedGuideRouteId; bool _mapCenterResolved = false; DateTime _now = DateTime.now(); Timer? _clockTimer; DateTime _focusedDay = DateTime.now(); DateTime? _selectedDay; CalendarFormat _calendarFormat = CalendarFormat.month; @override void initState() { super.initState(); _showLiveFeatures = widget.enableLiveFeatures; if (!_showLiveFeatures) { _isLoadingLocation = false; _loadingAddresses = false; } _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) { return; } setState(() { _now = DateTime.now(); }); }); unawaited(_loadLocalSeedData()); unawaited(_loadLocation()); unawaited(_loadAddresses()); } @override void dispose() { _truckTimer?.cancel(); _clockTimer?.cancel(); _calendarNoteController.dispose(); _newHouseNumberController.dispose(); _newColoniaController.dispose(); _newStreetController.dispose(); super.dispose(); } DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day); String _formatTimeAsAmPm(TimeOfDay timeOfDay) { final hour = timeOfDay.hourOfPeriod == 0 ? 12 : timeOfDay.hourOfPeriod; final minute = timeOfDay.minute.toString().padLeft(2, '0'); final suffix = timeOfDay.period == DayPeriod.am ? 'AM' : 'PM'; return '$hour:$minute $suffix'; } String _formatScheduleAmPm(String schedule) { final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); if (segments.length != 2) { return schedule; } TimeOfDay? parseSegment(String value) { final parts = value.split(':'); if (parts.length != 2) { return null; } final hour = int.tryParse(parts[0].trim()); final minute = int.tryParse(parts[1].trim()); if (hour == null || minute == null) { return null; } return TimeOfDay(hour: hour, minute: minute); } final start = parseSegment(segments[0]); final end = parseSegment(segments[1]); if (start == null || end == null) { return schedule; } return '${_formatTimeAsAmPm(start)} - ${_formatTimeAsAmPm(end)}'; } int? _weekdayFromSpanish(String day) { switch (day.trim().toLowerCase()) { case 'lunes': return DateTime.monday; case 'martes': return DateTime.tuesday; case 'miercoles': case 'miércoles': return DateTime.wednesday; case 'jueves': return DateTime.thursday; case 'viernes': return DateTime.friday; case 'sabado': case 'sábado': return DateTime.saturday; case 'domingo': return DateTime.sunday; default: return null; } } DateTime? _parseScheduleStart(String schedule, DateTime baseDate) { final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); if (segments.isEmpty) { return null; } final parts = segments.first.split(':'); if (parts.length != 2) { return null; } final hour = int.tryParse(parts[0].trim()); final minute = int.tryParse(parts[1].trim()); if (hour == null || minute == null) { return null; } return DateTime(baseDate.year, baseDate.month, baseDate.day, hour, minute); } DateTime? _nextGuideArrival(RouteGuideEntry guide, DateTime reference) { final availableWeekdays = guide.days .map(_weekdayFromSpanish) .whereType() .toList(growable: false); if (availableWeekdays.isEmpty) { return null; } for (var offset = 0; offset < 8; offset++) { final candidateDate = reference.add(Duration(days: offset)); if (!availableWeekdays.contains(candidateDate.weekday)) { continue; } final candidateStart = _parseScheduleStart(guide.schedule, candidateDate); if (candidateStart == null) { return null; } if (candidateStart.isAfter(reference)) { return candidateStart; } if (offset == 0 && candidateStart.difference(reference).inMinutes.abs() <= 30) { return candidateStart; } } final nextDay = reference.add(const Duration(days: 7)); final nextMatch = guide.days.map(_weekdayFromSpanish).whereType().first; var searchDate = nextDay; while (searchDate.weekday != nextMatch) { searchDate = searchDate.add(const Duration(days: 1)); } return _parseScheduleStart(guide.schedule, searchDate); } String _formatDuration(Duration duration) { final totalMinutes = duration.inMinutes; if (totalMinutes <= 0) { return 'ahora'; } final hours = totalMinutes ~/ 60; final minutes = totalMinutes % 60; if (hours > 0) { return minutes > 0 ? '$hours h $minutes min' : '$hours h'; } return '$minutes min'; } LatLng _fallbackMapCenter(LocalSeedData seedData) { final route = seedData.routeForColonia(widget.savedAddress?.colonia) ?? seedData.defaultRoute; if (route == null || route.positions.isEmpty) { return const LatLng(20.5111, -100.9037); } final points = route.positions; final averageLat = points.map((point) => point.lat).reduce((value, element) => value + element) / points.length; final averageLng = points.map((point) => point.lng).reduce((value, element) => value + element) / points.length; return LatLng(averageLat, averageLng); } List _projectRoutePoints(TruckRoute? route, LatLng center) { final positions = route?.positions; if (positions == null || positions.isEmpty) { return []; } final baseLat = positions.first.lat; final baseLng = positions.first.lng; return positions .map( (position) => LatLng( center.latitude + (position.lat - baseLat), center.longitude + (position.lng - baseLng), ), ) .toList(growable: false); } void _moveMapTo(LatLng targetCenter) { _center = targetCenter; if (_mapCenterResolved) { _mapController.move(targetCenter, 15); return; } _mapCenterResolved = true; } Future _loadLocalSeedData() async { final seedData = await LocalSeedRepository.instance.load(); if (!mounted) { return; } final selectedColonyRoute = seedData.colonyRouteForColonia(widget.savedAddress?.colonia ?? widget.session.displayName); final activeRoute = seedData.routeForColonia(widget.savedAddress?.colonia) ?? seedData.defaultRoute; setState(() { _seedData = seedData; _selectedColonyRoute = selectedColonyRoute; _activeRoute = activeRoute; _selectedGuideRouteId = activeRoute?.routeId ?? seedData.defaultRoute?.routeId; if (!_showLiveFeatures) { final fallbackCenter = _fallbackMapCenter(seedData); _moveMapTo(fallbackCenter); if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; _truckVisible = true; } } }); _startTruckSimulation(); } void _addNotification(String message) { if (!mounted) { return; } setState(() { _notifications.insert(0, _AppNotification(message: message, timestamp: DateTime.now())); }); } void _seedTruckSimulation() { final visible = _random.nextBool(); _truckVisible = visible; _truckPosition = _truckOffsetFromCenter(_random.nextDouble() * 20.0); _notifications.add( _AppNotification( message: visible ? 'El camión de basura apareció cerca de tu ubicación.' : 'El camión de basura está fuera de rango.', timestamp: DateTime.now(), ), ); } void _startRandomTruckSimulation() { _truckTimer?.cancel(); _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) { final distanceMeters = _random.nextDouble() * 40.0; final newTruckPosition = _truckOffsetFromCenter(distanceMeters); final wasVisible = _truckVisible; final isVisible = distanceMeters <= 20.0; if (!mounted) { return; } setState(() { _truckPosition = newTruckPosition; _truckVisible = isVisible; }); if (isVisible != wasVisible) { _addNotification( isVisible ? 'El camión de basura apareció a ${distanceMeters.toStringAsFixed(1)} m.' : 'El camión de basura salió del rango visible.', ); } }); } Future _loadLocation() async { try { final permission = await Geolocator.checkPermission(); var currentPermission = permission; if (currentPermission == LocationPermission.denied) { currentPermission = await Geolocator.requestPermission(); } if (currentPermission == LocationPermission.denied || currentPermission == LocationPermission.deniedForever) { if (!mounted) { return; } setState(() { _locationError = 'Permiso de ubicación no concedido. Mostrando mapa por defecto.'; _isLoadingLocation = false; }); if (_seedData != null) { final fallbackCenter = _fallbackMapCenter(_seedData!); if (mounted) { setState(() { _center = fallbackCenter; if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; _truckVisible = true; } }); _mapController.move(fallbackCenter, 15); } } return; } final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); final newCenter = LatLng(position.latitude, position.longitude); if (!mounted) { return; } setState(() { _moveMapTo(newCenter); _isLoadingLocation = false; _locationError = null; }); _mapController.move(newCenter, 15); } catch (_) { if (!mounted) { return; } setState(() { _locationError = 'No se pudo obtener la ubicación actual. Se usó una referencia por defecto.'; _isLoadingLocation = false; }); if (_seedData != null) { final fallbackCenter = _fallbackMapCenter(_seedData!); setState(() { _center = fallbackCenter; if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; _truckVisible = true; } }); _mapController.move(fallbackCenter, 15); } } } Future _loadAddresses() async { try { final addresses = await widget.addressRepository.getMyAddresses(session: widget.session); if (!mounted) { return; } setState(() { _addresses = addresses; _loadingAddresses = false; _addressesError = null; }); } catch (error) { if (!mounted) { return; } setState(() { _addressesError = error.toString(); _loadingAddresses = false; }); } } void _startTruckSimulation() { final route = _activeRoute; final positions = route?.positions; if (positions == null || positions.isEmpty) { _startRandomTruckSimulation(); return; } _truckTimer?.cancel(); _routePlaybackIndex = 0; _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) { if (!mounted) { return; } final projectedRoute = _projectRoutePoints(route, _center); final currentPosition = positions[_routePlaybackIndex % positions.length]; final currentLatLng = projectedRoute.isEmpty ? LatLng(currentPosition.lat, currentPosition.lng) : projectedRoute[_routePlaybackIndex % projectedRoute.length]; final distanceMeters = Geolocator.distanceBetween( _center.latitude, _center.longitude, currentLatLng.latitude, currentLatLng.longitude, ); final wasVisible = _truckVisible; final isVisible = distanceMeters <= 20.0; setState(() { _truckPosition = currentLatLng; _truckVisible = isVisible; }); _pushRouteNotification(currentPosition.positionId); if (isVisible != wasVisible) { _addNotification( isVisible ? 'El camión apareció sobre la ruta ${route?.routeId ?? 'RUTA'}.' : 'El camión salió del rango visible.', ); } _routePlaybackIndex++; }); } void _pushRouteNotification(int positionId) { final triggerEvent = switch (positionId) { 2 => 'ROUTE_START', 4 => 'TRUCK_PROXIMITY', 8 => 'ROUTE_COMPLETED', _ => '', }; final notification = _notificationByTrigger(triggerEvent); if (notification != null) { _addNotification('${notification.title}: ${notification.body}'); } } RouteNotification? _notificationByTrigger(String triggerEvent) { if (triggerEvent.isEmpty) { return null; } final seedData = _seedData; if (seedData == null) { return null; } for (final notification in seedData.notifications) { if (notification.triggerEvent == triggerEvent) { return notification; } } return null; } LatLng _truckOffsetFromCenter(double distanceMeters) { final angle = _random.nextDouble() * 2 * pi; final metersPerDegreeLat = 111320.0; final metersPerDegreeLng = 111320.0 * cos(_center.latitude * pi / 180.0).abs().clamp(0.2, 1.0); final deltaLat = (distanceMeters * cos(angle)) / metersPerDegreeLat; final deltaLng = (distanceMeters * sin(angle)) / metersPerDegreeLng; return LatLng(_center.latitude + deltaLat, _center.longitude + deltaLng); } Future _logOut(BuildContext context) async { await widget.authRepository.signOut(); if (!context.mounted) { return; } Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (_) => AuthScreen( authRepository: widget.authRepository, addressRepository: widget.addressRepository, ), ), (route) => false, ); } Future _saveCalendarNote() async { final note = _calendarNoteController.text.trim(); if (note.isEmpty || _selectedDay == null) { return; } final normalizedDay = _normalizeDay(_selectedDay!); setState(() { _calendarNotes[normalizedDay] = note; }); _calendarNoteController.clear(); _addNotification('Se guardó texto en el calendario para ${_selectedDay!.day}/${_selectedDay!.month}.'); } void _selectCalendarDay(DateTime day, DateTime focusedDay) { final normalizedDay = _normalizeDay(day); final savedNote = _calendarNotes[normalizedDay]; setState(() { _selectedDay = day; _focusedDay = focusedDay; _calendarNoteController.text = savedNote ?? ''; }); } CalendarEventEntry? _seedEventForDay(DateTime day) { final seedData = _seedData; if (seedData == null) { return null; } final normalizedDay = _normalizeDay(day); for (final event in seedData.calendarEvents) { if (event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) { return event; } } return null; } Future _saveNewAddress() async { if (_newHouseNumberController.text.trim().isEmpty || _newColoniaController.text.trim().isEmpty || _newStreetController.text.trim().isEmpty) { _addNotification('Completa la nueva dirección antes de guardarla.'); return; } final newAddress = AddressEntry( houseNumber: _newHouseNumberController.text.trim(), colonia: _newColoniaController.text.trim(), street: _newStreetController.text.trim(), ); try { await widget.addressRepository.saveAddress(session: widget.session, address: newAddress); if (!mounted) { return; } setState(() { _addresses = [ AddressRecord( id: DateTime.now().millisecondsSinceEpoch, houseNumber: newAddress.houseNumber, colonia: newAddress.colonia, street: newAddress.street, ), ..._addresses, ]; }); _newHouseNumberController.clear(); _newColoniaController.clear(); _newStreetController.clear(); _addNotification('Se agregó una nueva dirección a tu perfil.'); } catch (error) { _addNotification('No se pudo guardar la nueva dirección: $error'); } } void _selectGuideRoute(String routeId) { setState(() { _selectedGuideRouteId = routeId; }); } @override Widget build(BuildContext context) { final routePoints = _projectRoutePoints(_activeRoute, _center); final selectedDay = _selectedDay == null ? null : _normalizeDay(_selectedDay!); final selectedSavedNote = selectedDay == null ? null : _calendarNotes[selectedDay]; final selectedSeedEvents = selectedDay == null ? [] : (_seedData?.eventsForDay(selectedDay) ?? []); final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final bottomNavigation = NavigationBar( selectedIndex: _selectedIndex, onDestinationSelected: (index) { setState(() { _selectedIndex = index; }); }, destinations: const [ NavigationDestination(icon: Icon(Icons.my_location_outlined), selectedIcon: Icon(Icons.my_location), label: 'Mapa'), NavigationDestination(icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: 'Calendario'), NavigationDestination(icon: Icon(Icons.notifications_outlined), selectedIcon: Icon(Icons.notifications), label: 'Avisos'), NavigationDestination(icon: Icon(Icons.input_outlined), selectedIcon: Icon(Icons.input), label: 'Datos'), ], ); final pages = [ _MapSection( center: _center, truckPosition: _truckPosition, truckVisible: _truckVisible, isLoadingLocation: _isLoadingLocation, locationError: _locationError, mapController: _mapController, showTiles: _showLiveFeatures, address: widget.savedAddress, activeRoute: _activeRoute, selectedColonyRoute: _selectedColonyRoute, routePoints: routePoints, ), _CalendarSection( focusedDay: _focusedDay, selectedDay: _selectedDay, calendarFormat: _calendarFormat, notes: _calendarNotes, seedEvents: _seedData?.calendarEvents ?? const [], selectedSavedNote: selectedSavedNote, selectedSeedEvents: selectedSeedEvents, noteController: _calendarNoteController, onSelectedDay: _selectCalendarDay, onFormatChanged: (format) { setState(() { _calendarFormat = format; }); }, onPageChanged: (focusedDay) { _focusedDay = focusedDay; }, onSaveNote: _saveCalendarNote, ), _NotificationsSection(notifications: _notifications), _DataSection( session: widget.session, loadingAddresses: _loadingAddresses, addressesError: _addressesError, addresses: _addresses, seedData: _seedData, activeRoute: _activeRoute, selectedGuideRouteId: _selectedGuideRouteId, selectedColonyRoute: _selectedColonyRoute, now: _now, onSelectGuideRoute: _selectGuideRoute, houseNumberController: _newHouseNumberController, coloniaController: _newColoniaController, streetController: _newStreetController, onSaveAddress: _saveNewAddress, ), ]; return Scaffold( body: SafeArea( child: isLandscape ? Row( children: [ NavigationRail( selectedIndex: _selectedIndex, onDestinationSelected: (index) { setState(() { _selectedIndex = index; }); }, labelType: NavigationRailLabelType.all, leading: Padding( padding: const EdgeInsets.only(top: 8), child: CircleAvatar( radius: 22, backgroundColor: Colors.teal.shade700, child: Text( widget.session.displayName.isNotEmpty ? widget.session.displayName[0].toUpperCase() : 'U', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), ), ), ), destinations: const [ NavigationRailDestination( icon: Icon(Icons.my_location_outlined), selectedIcon: Icon(Icons.my_location), label: Text('Mapa'), ), NavigationRailDestination( icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: Text('Calendario'), ), NavigationRailDestination( icon: Icon(Icons.notifications_outlined), selectedIcon: Icon(Icons.notifications), label: Text('Avisos'), ), NavigationRailDestination( icon: Icon(Icons.input_outlined), selectedIcon: Icon(Icons.input), label: Text('Datos'), ), ], ), const VerticalDivider(width: 1), Expanded( child: Column( children: [ _UserHeader( session: widget.session, onLogout: () => _logOut(context), ), Expanded( child: IndexedStack( index: _selectedIndex, children: pages, ), ), ], ), ), ], ) : Column( children: [ _UserHeader( session: widget.session, onLogout: () => _logOut(context), ), Expanded( child: IndexedStack( index: _selectedIndex, children: pages, ), ), ], ), ), bottomNavigationBar: isLandscape ? Offstage(offstage: true, child: bottomNavigation) : bottomNavigation, ); } } class _UserHeader extends StatelessWidget { const _UserHeader({required this.session, required this.onLogout}); final AuthSession session; final VoidCallback onLogout; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( gradient: LinearGradient( colors: [Colors.teal.shade900, Colors.teal.shade700], ), ), child: Row( children: [ CircleAvatar( radius: 24, backgroundColor: Colors.white.withValues(alpha: 0.18), child: Text( session.displayName.isNotEmpty ? session.displayName[0].toUpperCase() : 'U', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(session.displayName, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)), Text(session.email, style: TextStyle(color: Colors.white.withValues(alpha: 0.85))), ], ), ), IconButton( onPressed: onLogout, icon: const Icon(Icons.logout, color: Colors.white), tooltip: 'Cerrar sesión', ), ], ), ); } } class _MapSection extends StatelessWidget { const _MapSection({ required this.center, required this.truckPosition, required this.truckVisible, required this.isLoadingLocation, required this.locationError, required this.mapController, required this.showTiles, required this.address, required this.activeRoute, required this.selectedColonyRoute, required this.routePoints, }); final LatLng center; final LatLng? truckPosition; final bool truckVisible; final bool isLoadingLocation; final String? locationError; final MapController mapController; final bool showTiles; final AddressEntry? address; final TruckRoute? activeRoute; final ColonyRoute? selectedColonyRoute; final List routePoints; @override Widget build(BuildContext context) { final markers = [ Marker( width: 60, height: 60, point: center, child: const Icon(Icons.person_pin_circle, size: 56, color: Colors.blue), ), if (truckVisible && truckPosition != null) Marker( width: 60, height: 60, point: truckPosition!, child: const Icon(Icons.local_shipping, size: 50, color: Colors.red), ), ]; return Container( color: const Color(0xFFF8FAFC), child: Column( children: [ if (locationError != null) Padding( padding: const EdgeInsets.all(12), child: _NoticeBanner(message: locationError!), ), Expanded( child: Center( child: Padding( padding: const EdgeInsets.all(16), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: Stack( children: [ if (showTiles) FlutterMap( mapController: mapController, options: MapOptions( initialCenter: center, initialZoom: 15, interactionOptions: const InteractionOptions(flags: InteractiveFlag.all), ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'flutter_application_1', ), if (routePoints.length > 1) PolylineLayer( polylines: [ Polyline( points: routePoints, strokeWidth: 4, color: Colors.teal.shade700, ), ], ), MarkerLayer(markers: markers), ], ) else _MapPlaceholder( center: center, truckVisible: truckVisible, truckPosition: truckPosition, ), Positioned( top: 16, left: 16, right: 16, child: Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('Mapa y camión', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 4), Text( address == null ? 'No hay una dirección guardada todavía.' : 'Dirección: ${address!.houseNumber}, ${address!.colonia}, ${address!.street}', style: TextStyle(color: Colors.grey.shade700), ), const SizedBox(height: 4), Text( activeRoute == null ? 'Ruta por defecto no disponible.' : 'Ruta activa: ${activeRoute!.routeId} - ${activeRoute!.name}', style: const TextStyle(fontWeight: FontWeight.w700), ), if (selectedColonyRoute != null) ...[ const SizedBox(height: 2), Text( 'Horario estimado: ${selectedColonyRoute!.horarioEstimado}', style: TextStyle(color: Colors.grey.shade700), ), ], const SizedBox(height: 4), Text( truckVisible ? 'El camión está dentro de 20 m.' : 'El camión está fuera de rango.', style: const TextStyle(fontWeight: FontWeight.w700), ), ], ), ), ), ), if (isLoadingLocation) const Center( child: CircularProgressIndicator(), ), ], ), ), ), ), ), ], ), ); } } class _MapPlaceholder extends StatelessWidget { const _MapPlaceholder({ required this.center, required this.truckVisible, required this.truckPosition, }); final LatLng center; final bool truckVisible; final LatLng? truckPosition; @override Widget build(BuildContext context) { return Container( color: const Color(0xFFE2E8F0), alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.map_outlined, size: 72, color: Colors.teal), const SizedBox(height: 12), const Text('Mapa centrado en tu ubicación', style: TextStyle(fontWeight: FontWeight.w700)), const SizedBox(height: 4), Text('Lat: ${center.latitude.toStringAsFixed(5)}, Lng: ${center.longitude.toStringAsFixed(5)}'), const SizedBox(height: 12), Text( truckVisible && truckPosition != null ? 'Camión visible cerca de ti' : 'Camión fuera del rango de 20 m', style: const TextStyle(fontWeight: FontWeight.w700), ), ], ), ); } } class _CalendarSection extends StatelessWidget { const _CalendarSection({ required this.focusedDay, required this.selectedDay, required this.calendarFormat, required this.notes, required this.seedEvents, required this.selectedSavedNote, required this.selectedSeedEvents, required this.noteController, required this.onSelectedDay, required this.onFormatChanged, required this.onPageChanged, required this.onSaveNote, }); final DateTime focusedDay; final DateTime? selectedDay; final CalendarFormat calendarFormat; final Map notes; final List seedEvents; final String? selectedSavedNote; final List selectedSeedEvents; final TextEditingController noteController; final void Function(DateTime day, DateTime focusedDay) onSelectedDay; final void Function(CalendarFormat format) onFormatChanged; final void Function(DateTime focusedDay) onPageChanged; final Future Function() onSaveNote; DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day); CalendarEventEntry? _seedEventForDay(DateTime day) { final normalizedDay = _normalizeDay(day); for (final event in seedEvents) { if (event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) { return event; } } return null; } @override Widget build(BuildContext context) { final selectedNormalized = selectedDay == null ? null : _normalizeDay(selectedDay!); final carouselDays = {}; if (selectedNormalized != null) { carouselDays.add(selectedNormalized); } for (final day in notes.keys) { carouselDays.add(_normalizeDay(day)); } for (final event in seedEvents) { carouselDays.add(_normalizeDay(event.date)); } final carouselDayList = carouselDays.toList()..sort((a, b) => a.compareTo(b)); return Container( color: const Color(0xFFF8FAFC), padding: const EdgeInsets.all(16), child: Column( children: [ Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TableCalendar( firstDay: DateTime.utc(2020, 1, 1), lastDay: DateTime.utc(2035, 12, 31), focusedDay: focusedDay, calendarFormat: calendarFormat, selectedDayPredicate: (day) => isSameDay(selectedDay, day), eventLoader: (day) { final normalizedDay = _normalizeDay(day); final userNote = notes[normalizedDay]; final seededTitles = seedEvents .where((event) => event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) .map((event) => event.title) .toList(growable: false); final events = []; if (userNote != null && userNote.isNotEmpty) { events.add(userNote); } events.addAll(seededTitles); return events; }, onDaySelected: onSelectedDay, onFormatChanged: onFormatChanged, onPageChanged: onPageChanged, calendarBuilders: CalendarBuilders( defaultBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; final seedEvent = _seedEventForDay(day); return _CalendarDayCell( day: day, note: note, seededTitle: seedEvent?.title, selected: false, ); }, selectedBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; final seedEvent = _seedEventForDay(day); return _CalendarDayCell( day: day, note: note, seededTitle: seedEvent?.title, selected: true, ); }, todayBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; final seedEvent = _seedEventForDay(day); return _CalendarDayCell( day: day, note: note, seededTitle: seedEvent?.title, selected: false, today: true, ); }, ), ), const SizedBox(height: 12), TextField( controller: noteController, maxLines: 2, decoration: const InputDecoration( labelText: 'Texto del día', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: onSaveNote, child: const Text('Guardar texto y resaltar casilla'), ), ), const SizedBox(height: 12), Text( selectedNormalized == null ? 'Desliza el carrusel para ver las fechas importantes.' : 'Fecha seleccionada: ${selectedNormalized.day}/${selectedNormalized.month}/${selectedNormalized.year}', style: TextStyle(color: Colors.grey.shade700), ), const SizedBox(height: 12), if (carouselDayList.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Text('No hay fechas importantes registradas todavía.'), ) else SizedBox( height: 170, child: PageView.builder( itemCount: carouselDayList.length, onPageChanged: (index) { final day = carouselDayList[index]; onSelectedDay(day, day); }, itemBuilder: (context, index) { final day = carouselDayList[index]; final note = notes[day]; final events = seedEvents .where((event) => event.date.year == day.year && event.date.month == day.month && event.date.day == day.day) .toList(growable: false); final isSelected = selectedNormalized != null && isSameDay(selectedNormalized, day); return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Card( elevation: isSelected ? 10 : 4, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), color: isSelected ? const Color(0xFFE6FFFB) : Colors.white, child: Padding( padding: const EdgeInsets.all(16), child: SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '${day.day}/${day.month}/${day.year}', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), ), const SizedBox(height: 8), Text( note == null || note.trim().isEmpty ? 'No hay texto guardado para esta fecha.' : note, style: TextStyle(color: Colors.grey.shade800), ), const SizedBox(height: 10), if (events.isEmpty) Text( 'Sin evento local para este día.', style: TextStyle(color: Colors.grey.shade600), ) else ...events.map( (event) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFD1D5DB)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(event.title, style: const TextStyle(fontWeight: FontWeight.w700)), const SizedBox(height: 4), Text(event.description, style: TextStyle(color: Colors.grey.shade700)), ], ), ), ), ), ], ), ), ), ), ); }, ), ), ], ), ), ), ], ), ); } } class _CalendarDayCell extends StatelessWidget { const _CalendarDayCell({ required this.day, required this.note, required this.seededTitle, required this.selected, this.today = false, }); final DateTime day; final String? note; final String? seededTitle; final bool selected; final bool today; @override Widget build(BuildContext context) { final backgroundColor = selected ? Colors.teal.shade700 : today ? Colors.teal.shade100 : Colors.white; final hasNote = note != null && note!.trim().isNotEmpty; final hasSeedTitle = seededTitle != null && seededTitle!.trim().isNotEmpty; return Container( margin: const EdgeInsets.all(4), padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(14), border: Border.all(color: hasNote || hasSeedTitle ? Colors.teal : Colors.grey.shade300), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( '${day.day}', style: TextStyle( color: selected ? Colors.white : Colors.black, fontWeight: FontWeight.w800, ), ), if (hasNote || hasSeedTitle) ...[ const SizedBox(height: 4), Wrap( alignment: WrapAlignment.center, spacing: 4, runSpacing: 4, children: [ if (hasNote) Container( width: 8, height: 8, decoration: BoxDecoration( color: selected ? Colors.white : Colors.teal.shade700, shape: BoxShape.circle, ), ), if (hasSeedTitle) Container( width: 8, height: 8, decoration: BoxDecoration( color: selected ? Colors.white : Colors.deepOrange.shade700, shape: BoxShape.circle, ), ), ], ), ], ], ), ); } } class _SelectedDaySummary extends StatelessWidget { const _SelectedDaySummary({ required this.day, required this.savedNote, required this.events, }); final DateTime day; final String? savedNote; final List events; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(18), border: Border.all(color: const Color(0xFF7DD3FC)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Detalle del día ${day.day}/${day.month}/${day.year}', style: const TextStyle(fontWeight: FontWeight.w800), ), const SizedBox(height: 8), Text( savedNote == null || savedNote!.trim().isEmpty ? 'No hay texto escrito por el usuario para este día.' : 'Texto guardado: $savedNote', style: TextStyle(color: Colors.grey.shade800), ), if (events.isNotEmpty) ...[ const SizedBox(height: 10), const Text('Eventos del calendario:', style: TextStyle(fontWeight: FontWeight.w700)), const SizedBox(height: 6), ...events.map( (event) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Text('• ${event.title}: ${event.description}'), ), ), ], ], ), ); } } class _NotificationsSection extends StatelessWidget { const _NotificationsSection({required this.notifications}); final List<_AppNotification> notifications; @override Widget build(BuildContext context) { return Container( color: const Color(0xFFF8FAFC), child: ListView.builder( padding: const EdgeInsets.all(16), itemCount: notifications.isEmpty ? 1 : notifications.length, itemBuilder: (context, index) { if (notifications.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.only(top: 48), child: Text('Aquí aparecerán las notificaciones.'), ), ); } final notification = notifications[index]; return Card( margin: const EdgeInsets.only(bottom: 12), child: ListTile( leading: const Icon(Icons.notifications_active, color: Colors.teal), title: Text(notification.message), subtitle: Text( '${notification.timestamp.hour.toString().padLeft(2, '0')}:${notification.timestamp.minute.toString().padLeft(2, '0')}', ), ), ); }, ), ); } } class _DataSection extends StatelessWidget { const _DataSection({ required this.session, required this.loadingAddresses, required this.addressesError, required this.addresses, required this.seedData, required this.activeRoute, required this.selectedGuideRouteId, required this.selectedColonyRoute, required this.now, required this.onSelectGuideRoute, required this.houseNumberController, required this.coloniaController, required this.streetController, required this.onSaveAddress, }); final AuthSession session; final bool loadingAddresses; final String? addressesError; final List addresses; final LocalSeedData? seedData; final TruckRoute? activeRoute; final String? selectedGuideRouteId; final ColonyRoute? selectedColonyRoute; final DateTime now; final void Function(String routeId) onSelectGuideRoute; final TextEditingController houseNumberController; final TextEditingController coloniaController; final TextEditingController streetController; final Future Function() onSaveAddress; @override Widget build(BuildContext context) { return Container( color: const Color(0xFFF8FAFC), padding: const EdgeInsets.all(16), child: ListView( children: [ Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Datos de la cuenta', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 12), _ProfileRow(label: 'Nombre', value: session.displayName), _ProfileRow(label: 'Correo', value: session.email), if (selectedColonyRoute != null) ...[ const SizedBox(height: 8), _ProfileRow(label: 'Colonia', value: selectedColonyRoute!.colonia), _ProfileRow(label: 'Ruta', value: selectedColonyRoute!.routeId), _ProfileRow(label: 'Horario', value: selectedColonyRoute!.horarioEstimado), ], ], ), ), ), const SizedBox(height: 16), Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Agregar otra dirección', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 12), TextField( controller: houseNumberController, decoration: const InputDecoration(labelText: 'Número de casa', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: coloniaController, decoration: const InputDecoration(labelText: 'Colonia', border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( controller: streetController, decoration: const InputDecoration(labelText: 'Calle', border: OutlineInputBorder()), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: onSaveAddress, icon: const Icon(Icons.add_location_alt_outlined), label: const Text('Guardar nueva dirección'), ), ), ], ), ), ), const SizedBox(height: 16), Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Guía de rutas', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 12), if (seedData == null || seedData!.routes.isEmpty) const Text('No hay rutas locales disponibles.'), if (seedData != null && seedData!.routes.isNotEmpty) ...seedData!.routes.map( (route) { final isSelected = route.routeId == selectedGuideRouteId; final guide = seedData!.guideForRouteId(route.routeId); return Card( margin: const EdgeInsets.only(bottom: 12), child: InkWell( onTap: () => onSelectGuideRoute(route.routeId), borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( route.routeId == activeRoute?.routeId ? Icons.route : Icons.alt_route, color: route.routeId == activeRoute?.routeId ? Colors.teal : Colors.grey.shade700, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${route.routeId} - ${route.name}'), const SizedBox(height: 4), Text( guide == null ? 'Camión ${route.truckId} • ${route.status} • ${route.positions.length} puntos' : 'Camión ${route.truckId} • ${guide.wasteType} • ${formatScheduleAsAmPm(guide.schedule)}', style: TextStyle(color: Colors.grey.shade700), ), ], ), ), Icon(isSelected ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down), ], ), if (isSelected && guide != null) ...[ const SizedBox(height: 14), _RouteGuideDetailContent(guide: guide, now: now), ], ], ), ), ), ); }, ), ], ), ), ), const SizedBox(height: 16), Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Direcciones registradas', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 12), if (loadingAddresses) const Center(child: CircularProgressIndicator()), if (addressesError != null) Text(addressesError!, style: const TextStyle(color: Colors.red)), if (!loadingAddresses && addresses.isEmpty) const Text('Todavía no hay direcciones registradas.'), if (addresses.isNotEmpty) ...addresses.map( (address) => Card( margin: const EdgeInsets.only(bottom: 12), child: ListTile( leading: const Icon(Icons.home_work_outlined, color: Colors.teal), title: Text('Casa ${address.houseNumber}'), subtitle: Text('${address.colonia}, ${address.street}'), ), ), ), ], ), ), ), ], ), ); } } class _ProfileRow extends StatelessWidget { const _ProfileRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 90, child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700))), Expanded(child: Text(value)), ], ), ); } } class _RouteGuideDetailContent extends StatelessWidget { const _RouteGuideDetailContent({required this.guide, required this.now}); final RouteGuideEntry guide; final DateTime now; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(18), border: Border.all(color: const Color(0xFFD1D5DB)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ProfileRow(label: 'Ruta', value: guide.routeId), _ProfileRow(label: 'Tipo', value: guide.wasteType), _ProfileRow(label: 'Horario', value: formatScheduleAsAmPm(guide.schedule)), _ProfileRow(label: 'Días', value: guide.days.join(', ')), const SizedBox(height: 6), Text( 'Cronómetro: ${guideCountdownText(guide, now)}', style: const TextStyle(fontWeight: FontWeight.w800), ), const SizedBox(height: 6), Text(guide.note, style: TextStyle(color: Colors.grey.shade700, height: 1.35)), ], ), ); } } class _AppNotification { _AppNotification({required this.message, required this.timestamp}); final String message; final DateTime timestamp; } class _NoticeBanner extends StatelessWidget { const _NoticeBanner({required this.message}); final String message; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFF7DD3FC)), ), child: Text(message, style: const TextStyle(color: Color(0xFF075985))), ); } }