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/address_record.dart'; import '../models/auth_session.dart'; import '../services/address_repository.dart'; import '../services/auth_repository.dart'; import 'auth_screen.dart'; 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; bool _loadingAddresses = true; String? _addressesError; List _addresses = []; DateTime _focusedDay = DateTime.now(); DateTime? _selectedDay; CalendarFormat _calendarFormat = CalendarFormat.month; @override void initState() { super.initState(); _showLiveFeatures = widget.enableLiveFeatures; if (!_showLiveFeatures) { _isLoadingLocation = false; _loadingAddresses = false; _seedTruckSimulation(); return; } unawaited(_loadLocation()); unawaited(_loadAddresses()); _startTruckSimulation(); } @override void dispose() { _truckTimer?.cancel(); _calendarNoteController.dispose(); _newHouseNumberController.dispose(); _newColoniaController.dispose(); _newStreetController.dispose(); super.dispose(); } DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day); 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(), ), ); } 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; }); return; } final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); final newCenter = LatLng(position.latitude, position.longitude); if (!mounted) { return; } setState(() { _center = 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; }); } } 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() { _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.', ); } }); } 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}.'); } 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'); } } @override Widget build(BuildContext context) { final pages = [ _MapSection( center: _center, truckPosition: _truckPosition, truckVisible: _truckVisible, isLoadingLocation: _isLoadingLocation, locationError: _locationError, mapController: _mapController, showTiles: _showLiveFeatures, address: widget.savedAddress, ), _CalendarSection( focusedDay: _focusedDay, selectedDay: _selectedDay, calendarFormat: _calendarFormat, notes: _calendarNotes, noteController: _calendarNoteController, onSelectedDay: (day, focusedDay) { setState(() { _selectedDay = day; _focusedDay = focusedDay; }); }, onFormatChanged: (format) { setState(() { _calendarFormat = format; }); }, onPageChanged: (focusedDay) { _focusedDay = focusedDay; }, onSaveNote: _saveCalendarNote, ), _NotificationsSection(notifications: _notifications), _DataSection( session: widget.session, loadingAddresses: _loadingAddresses, addressesError: _addressesError, addresses: _addresses, houseNumberController: _newHouseNumberController, coloniaController: _newColoniaController, streetController: _newStreetController, onSaveAddress: _saveNewAddress, ), ]; return Scaffold( body: SafeArea( child: Column( children: [ _UserHeader( session: widget.session, onLogout: () => _logOut(context), ), Expanded( child: IndexedStack( index: _selectedIndex, children: pages, ), ), ], ), ), bottomNavigationBar: 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'), ], ), ); } } 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, }); final LatLng center; final LatLng? truckPosition; final bool truckVisible; final bool isLoadingLocation; final String? locationError; final MapController mapController; final bool showTiles; final AddressEntry? address; @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', ), 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( 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.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 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); @override Widget build(BuildContext context) { final selectedNormalized = selectedDay == null ? null : _normalizeDay(selectedDay!); 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) => notes.containsKey(_normalizeDay(day)) ? [notes[_normalizeDay(day)]!] : [], onDaySelected: onSelectedDay, onFormatChanged: onFormatChanged, onPageChanged: onPageChanged, calendarBuilders: CalendarBuilders( defaultBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; if (note == null) { return null; } return _CalendarDayCell(day: day, note: note, selected: false); }, selectedBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; return _CalendarDayCell(day: day, note: note, selected: true); }, todayBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; return _CalendarDayCell(day: day, note: note, 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 ? 'Selecciona un día para escribir.' : 'Día seleccionado: ${selectedNormalized.day}/${selectedNormalized.month}/${selectedNormalized.year}', style: TextStyle(color: Colors.grey.shade700), ), ], ), ), ), ], ), ); } } class _CalendarDayCell extends StatelessWidget { const _CalendarDayCell({ required this.day, required this.note, required this.selected, this.today = false, }); final DateTime day; final String? note; final bool selected; final bool today; @override Widget build(BuildContext context) { final backgroundColor = selected ? Colors.teal.shade700 : today ? Colors.teal.shade100 : Colors.white; return Container( margin: const EdgeInsets.all(4), padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(14), border: Border.all(color: note == null ? Colors.grey.shade300 : Colors.teal), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${day.day}', style: TextStyle( color: selected ? Colors.white : Colors.black, fontWeight: FontWeight.w800, ), ), if (note != null) ...[ const SizedBox(height: 4), Text( note!, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: 10, color: selected ? Colors.white : Colors.teal.shade900, fontWeight: FontWeight.w700, ), ), ], ], ), ); } } 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.houseNumberController, required this.coloniaController, required this.streetController, required this.onSaveAddress, }); final AuthSession session; final bool loadingAddresses; final String? addressesError; final List addresses; 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), ], ), ), ), 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('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 _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))), ); } }