import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; import 'package:professional_repository/professional_repository.dart'; import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/screens/service/professional_service_screen.dart'; import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:service_repository/service_repository.dart'; import 'package:table_calendar/table_calendar.dart'; const _kPrimary = Color(0xFF1565C0); const _kAvailable = Color(0xFF16A34A); const _kOccupied = Color(0xFFDC2626); extension _Th on BuildContext { ThemeData get _t => Theme.of(this); Color get bg => _t.scaffoldBackgroundColor; Color get card => _t.cardColor; Color get onSurface => _t.colorScheme.onSurface; Color get muted => _t.colorScheme.onSurface.withOpacity(0.55); Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35); bool get isDark => _t.brightness == Brightness.dark; Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.07); Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04); } class ProfessionalCalendarScreen extends StatefulWidget { final ProfessionalEntity userProfessional; const ProfessionalCalendarScreen({super.key, required this.userProfessional}); @override State createState() => _ProfessionalCalendarScreenState(); } class _ProfessionalCalendarScreenState extends State { final serviceRepository = Injector.appInstance.get(); DateTime today = DateTime.now(); late int numDay; List? _services; CalendarFormat _calendarFormat = CalendarFormat.month; bool isLoading = false; bool _loadFailed = false; @override void initState() { super.initState(); today = DateTime.utc(today.year, today.month, today.day); numDay = today.weekday; _loadServices(); } void _loadServices() { setState(() => _loadFailed = false); serviceRepository .getServicesForProfessionalforCalendar(widget.userProfessional.id) .then((services) { if (!mounted) return; setState(() => _services = services); }).catchError((e) { if (!mounted) return; // Never fall back to an empty list: booked slots would render as free. setState(() => _loadFailed = true); }); } void _onDaySelected(DateTime day, DateTime focusedDay) { setState(() { today = day; numDay = today.weekday; }); } @override Widget build(BuildContext context) { final schedule = _scheduleFromDay(numDay); final slots = _buildSlots(schedule); final occupied = slots.where((t) => _isOccupied(t, _services, today)).length; final available = slots.length - occupied; return Scaffold( backgroundColor: context.bg, appBar: AppBar( title: const Text('Calendario'), backgroundColor: _kPrimary, foregroundColor: Colors.white, elevation: 0, ), body: BlocProvider( create: (context) => Injector.appInstance.get(), child: BlocConsumer( listener: (context, state) { if (state is CreateServiceLoading) isLoading = true; if (state is CreateServiceFailure) { isLoading = false; ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No se pudo completar la acción')), ); } if (state is CreateServiceSuccess || state is ServiceStatusUpdated) { isLoading = false; _loadServices(); } }, builder: (context, state) { return ListView( padding: const EdgeInsets.only(bottom: 32), children: [ _calendarCard(context), if (_loadFailed) _loadErrorState(context) else ...[ _dayHeader( context, schedule, slots.length, occupied, available), if (slots.isEmpty) _emptyState(context) else ..._slotCards(context, slots, state), ], ], ); }, ), ), ); } Widget _calendarCard(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), decoration: BoxDecoration( color: context.card, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: context.shadow, blurRadius: 16, offset: const Offset(0, 4)) ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: TableCalendar( locale: 'es_MX', firstDay: DateTime.now(), lastDay: today.add(const Duration(days: 365)), focusedDay: today, availableGestures: AvailableGestures.all, onDaySelected: _onDaySelected, selectedDayPredicate: (day) => isSameDay(day, today), calendarFormat: _calendarFormat, onFormatChanged: (f) => setState(() => _calendarFormat = f), availableCalendarFormats: const { CalendarFormat.month: 'Mes', CalendarFormat.week: 'Semana', CalendarFormat.twoWeeks: '2 Semanas', }, calendarStyle: CalendarStyle( todayDecoration: BoxDecoration( border: Border.all(color: _kPrimary, width: 2), shape: BoxShape.circle), todayTextStyle: const TextStyle( color: _kPrimary, fontWeight: FontWeight.w700), selectedDecoration: const BoxDecoration( color: _kPrimary, shape: BoxShape.circle), selectedTextStyle: const TextStyle( color: Colors.white, fontWeight: FontWeight.w700), weekendTextStyle: TextStyle(color: Colors.red.shade400), defaultTextStyle: TextStyle(color: context.onSurface), outsideDaysVisible: false, ), headerStyle: HeaderStyle( formatButtonDecoration: BoxDecoration( border: Border.all(color: _kPrimary.withOpacity(0.4)), borderRadius: BorderRadius.circular(8), ), formatButtonTextStyle: const TextStyle(color: _kPrimary, fontSize: 12), titleCentered: true, titleTextStyle: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: context.onSurface), leftChevronIcon: const Icon(Icons.chevron_left, color: _kPrimary), rightChevronIcon: const Icon(Icons.chevron_right, color: _kPrimary), ), daysOfWeekStyle: DaysOfWeekStyle( weekdayStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: context.muted), weekendStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFEF4444)), ), ), ), ); } Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int available) { final dayName = DateFormat('EEEE', 'es').format(today); final dateStr = DateFormat('d MMMM yyyy', 'es').format(today); final hasSchedule = schedule != null && schedule.enabled && total > 0; return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: context.card, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2)) ], ), child: Row(children: [ Container( width: 48, height: 52, decoration: BoxDecoration(color: _kPrimary, borderRadius: BorderRadius.circular(12)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Text(DateFormat('d').format(today), style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.w800, height: 1)), Text(DateFormat('MMM', 'es').format(today).toUpperCase(), style: const TextStyle( color: Colors.white70, fontSize: 10, fontWeight: FontWeight.w600)), ]), ), const SizedBox(width: 14), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_capitalize(dayName), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: context.onSurface)), Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)), ]), ), if (hasSchedule) ...[ _StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied), const SizedBox(width: 8), _StatPill( label: '$available', sublabel: 'libres', color: _kAvailable), ], ]), ); } List _slotCards( BuildContext context, List slots, ServiceState state) { return slots.map((time) { final occ = _isOccupied(time, _services, today); final color = occ ? _kOccupied : _kAvailable; return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), decoration: BoxDecoration( color: context.card, borderRadius: BorderRadius.circular(14), boxShadow: [ BoxShadow( color: context.shadowSm, blurRadius: 8, offset: const Offset(0, 2)) ], ), child: ClipRRect( borderRadius: BorderRadius.circular(14), child: InkWell( onTap: () => occ ? _onOccupied(time) : _onAvailable(context, time, state), child: IntrinsicHeight( child: Row(children: [ Container(width: 4, color: color), Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12), child: Row(children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: BoxDecoration( color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(8)), child: Text( ScheduleEntity.getFormatTime(time) ?? '', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: color), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(occ ? 'Ocupado' : 'Disponible', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: color)), Text( occ ? 'Toca para ver el servicio' : 'Horario libre · toca para reservar', style: TextStyle( fontSize: 11, color: context.subtle), ), ], ), ), Container( width: 32, height: 32, decoration: BoxDecoration( color: color.withOpacity(0.1), shape: BoxShape.circle), child: Icon( occ ? Icons.event_busy_outlined : Icons.event_available_outlined, size: 17, color: color), ), ]), ), ), ]), ), ), ), ); }).toList(); } void _onOccupied(TimeOfDay time) { if (_services == null) return; for (final event in _services!) { if (today.toString() == event.day && time == event.range1Hour1) { if (event.userId == event.professionalId) { _confirmUnblock(event.id!, time); } else { Navigator.push( context, CupertinoPageRoute( builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)), ); } return; } } } void _confirmUnblock(String serviceId, TimeOfDay time) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Desbloquear horario'), content: Text( '¿Quieres liberar el horario de las ' '${ScheduleEntity.getFormatTime(time)} ' 'del ${DateFormat('dd-MM-yyyy').format(today)}?', ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancelar'), ), TextButton( onPressed: () { Navigator.pop(dialogContext); context .read() .add(UpdateServiceStatus(serviceId, ServiceStatus.cancelled)); }, child: const Text('Desbloquear'), ), ], ), ); } void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Reservar hora'), content: Column(mainAxisSize: MainAxisSize.min, children: [ Text( '¿Reservar a las ${ScheduleEntity.getFormatTime(time)} ' 'del ${DateFormat('dd-MM-yyyy').format(today)}?', ), const SizedBox(height: 10), const Text('⚠️ Esta acción no se puede deshacer', style: TextStyle(fontWeight: FontWeight.bold)), ]), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancelar'), ), TextButton( onPressed: () { Navigator.pop(dialogContext); Navigator.pop(context); context.read().add(CreateService( professionalId: widget.userProfessional.id, userId: widget.userProfessional.id, address: widget.userProfessional.address, aditionalAddress: '', latitude: 0, longitude: 0, day: today.toString(), createdAt: DateTime.now().toIso8601String(), description: '', range1Hour1: time, range1Hour2: time.add( minute: widget.userProfessional.slotDurationMinutes), rate: '0', location: ServiceLocationPreferences.office, status: ServiceStatus.selfBooked, )); }, child: const Text('Reservar'), ), TextButton( onPressed: () { Navigator.pop(dialogContext); context.read().add( BlockSlot(day: today.toString(), hour1: time), ); }, child: const Text('Bloquear horario'), ), ], ), ); } Widget _loadErrorState(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), decoration: BoxDecoration( color: context.card, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2)) ], ), child: Column(children: [ Container( width: 64, height: 64, decoration: BoxDecoration( color: _kOccupied.withOpacity(0.1), shape: BoxShape.circle), child: const Icon(Icons.wifi_off_outlined, size: 32, color: _kOccupied), ), const SizedBox(height: 16), Text('No se pudo cargar tu agenda', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)), const SizedBox(height: 6), Text( 'No mostramos horarios para evitar que reserves\nsobre una cita ya agendada.', textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)), const SizedBox(height: 16), OutlinedButton( onPressed: _loadServices, child: const Text('Reintentar')), ]), ); } Widget _emptyState(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), decoration: BoxDecoration( color: context.card, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2)) ], ), child: Column(children: [ Container( width: 64, height: 64, decoration: BoxDecoration( color: context.subtle.withOpacity(0.1), shape: BoxShape.circle), child: Icon(Icons.event_busy_outlined, size: 32, color: context.subtle), ), const SizedBox(height: 16), Text('Sin horario este día', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)), const SizedBox(height: 6), Text( 'No tienes horario de atención configurado\npara este día de la semana.', textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)), ]), ); } ScheduleEntity? _scheduleFromDay(int day) { switch (day) { case 1: return widget.userProfessional.schedules.monday; case 2: return widget.userProfessional.schedules.tuesday; case 3: return widget.userProfessional.schedules.wednesday; case 4: return widget.userProfessional.schedules.thursday; case 5: return widget.userProfessional.schedules.friday; case 6: return widget.userProfessional.schedules.saturday; default: return widget.userProfessional.schedules.sunday; } } List _buildSlots(ScheduleEntity? s) { if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) { return []; } final stepMinutes = widget.userProfessional.slotDurationMinutes; if (s.continuousDay) { return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, stepMinutes: stepMinutes); } if (s.range1Hour2 == null || s.range2Hour1 == null) return []; return [ ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes), ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes), ]; } bool _isOccupied( TimeOfDay time, List? services, DateTime day) { if (services == null) return false; return services .any((s) => day.toString() == s.day && s.range1Hour1 == time); } String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); } class _StatPill extends StatelessWidget { final String label; final String sublabel; final Color color; const _StatPill( {required this.label, required this.sublabel, required this.color}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withOpacity(0.25)), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: color, height: 1)), Text(sublabel, style: TextStyle( fontSize: 9, color: color.withOpacity(0.8), fontWeight: FontWeight.w600)), ]), ); } }