From 142e3ffafd2184003c3b7037cff66031f85dd855 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:41:34 -0500 Subject: [PATCH] Redesign calendar and requests views - professional_calendar_view: styled TableCalendar with app colors, day header with date block + stats pills (occupied/available), time slot cards with accent strip + status icon, clean empty state - services_requests_view: card list with status-colored accent strips, avatar initials fallback, date/time row, colored status badges with icons for all ServiceStatus values, proper empty state Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/views/professional_calendar_view.dart | 798 ++++++++++++------- lib/ui/views/services_requests_view.dart | 457 ++++++++--- 2 files changed, 848 insertions(+), 407 deletions(-) diff --git a/lib/ui/views/professional_calendar_view.dart b/lib/ui/views/professional_calendar_view.dart index df2233e..e1fb721 100644 --- a/lib/ui/views/professional_calendar_view.dart +++ b/lib/ui/views/professional_calendar_view.dart @@ -2,17 +2,22 @@ import 'package:intl/intl.dart'; import 'package:prosapp_web_app/models/profesional.dart'; import 'package:prosapp_web_app/models/schedules_entity.dart'; import 'package:prosapp_web_app/models/service.dart'; -import 'package:prosapp_web_app/models/usuario.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/professional_form_provider.dart'; import 'package:prosapp_web_app/providers/professional_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart'; -import 'package:prosapp_web_app/ui/cards/white_card.dart'; import 'package:prosapp_web_app/utils/time_of_day_utils.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:flutter/material.dart'; +const _kPrimary = Color(0xFF1565C0); +const _kAccent = Color(0xFF42A4EF); +const _kBg = Color(0xFFF4F6FA); +const _kCard = Colors.white; +const _kAvailable = Color(0xFF16A34A); +const _kOccupied = Color(0xFFDC2626); + class ProfessionalCalendarView extends StatefulWidget { const ProfessionalCalendarView({super.key}); @@ -21,336 +26,535 @@ class ProfessionalCalendarView extends StatefulWidget { _ProfessionalCalendarViewState(); } -class _ProfessionalCalendarViewState extends State { - DateTime today = DateTime.now(); - late int numDay; - +class _ProfessionalCalendarViewState + extends State { + DateTime _selected = DateTime.now(); List? _services; - - Usuario? user; + bool _loading = true; @override void initState() { super.initState(); - numDay = today.weekday; - _fetchProfessionalAndServices(); + _load(); } - void _fetchProfessionalAndServices() async { - final authProvider = Provider.of(context, listen: false); - final professionalFormProvider = Provider.of(context, listen: false); - final servicesProvider = Provider.of(context, listen: false); - - - - final proProvider = Provider.of(context, listen: false); - - final professional = await proProvider.getProfessional(authProvider.user!.id); - professionalFormProvider.setProfesional(professional); - - final services = await servicesProvider.getServicesForProfessional(professional.id); - setState(() { - _services = services; - user = authProvider.user; - }); + Future _load() async { + setState(() => _loading = true); + final auth = Provider.of(context, listen: false); + final fp = Provider.of(context, listen: false); + final sp = Provider.of(context, listen: false); + final pro = await Provider.of(context, listen: false) + .getProfessional(auth.user!.id); + fp.setProfesional(pro); + final services = await sp.getServicesForProfessional(pro.id); + if (mounted) setState(() { _services = services; _loading = false; }); } - void _onDaySelected(DateTime day, DateTime focusedDay) { - setState(() { - today = day; - numDay = today.weekday; - }); - _fetchProfessionalAndServices(); - } + void _onDaySelected(DateTime day, DateTime _) => + setState(() => _selected = day); @override Widget build(BuildContext context) { - return Consumer( - builder: (context, professionalFormProvider, child) { - if (professionalFormProvider.profesional == null) { - return const Center( - child: CircularProgressIndicator(), + return Container( + color: _kBg, + child: Consumer( + builder: (context, fp, _) { + if (fp.profesional == null || _loading) { + return const Center( + child: CircularProgressIndicator(color: _kPrimary), + ); + } + final pro = fp.profesional!; + final schedule = _scheduleFor(_selected.weekday, pro); + final slots = _buildSlots(schedule); + final occupied = slots + .where((t) => _isOccupied(t, _services, _selected)) + .length; + + return ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 32), + children: [ + _calendarCard(), + _dayHeader(schedule, slots.length, occupied), + if (slots.isEmpty) + _emptyState() + else + ..._slotCards(slots, schedule), + ], ); - } + }, + ), + ); + } - final profesional = professionalFormProvider.profesional!; + // ── Calendar card ───────────────────────────────────────────────────────── - return ListView( - physics: const ClampingScrollPhysics(), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: WhiteCard( - title: 'Calendario', - child: Column( - children: [ - TableCalendar( - locale: 'es_CO', - firstDay: DateTime.now(), - lastDay: DateTime.utc(2030, 3, 14), - focusedDay: today, - availableGestures: AvailableGestures.all, - onDaySelected: _onDaySelected, - selectedDayPredicate: (day) => isSameDay(day, today), - ), - const Divider(height: 0), - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 8, - ), - child: Text( - DateFormat('dd MMMM yyyy', 'es').format(today), - style: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const Divider(height: 0), - Column( - children: [ - ..._rangesItems(_getScheduleFromNumDay(numDay, profesional),context), - ], - ), - ], - ), + Widget _calendarCard() { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 20, 16, 0), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.07), + blurRadius: 16, + offset: const Offset(0, 4), + ) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: TableCalendar( + locale: 'es_CO', + firstDay: DateTime.now().subtract(const Duration(days: 365)), + lastDay: DateTime.utc(2030, 12, 31), + focusedDay: _selected, + availableGestures: AvailableGestures.all, + onDaySelected: _onDaySelected, + selectedDayPredicate: (d) => isSameDay(d, _selected), + 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), + outsideDaysVisible: false, + ), + headerStyle: const HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + leftChevronIcon: + Icon(Icons.chevron_left, color: _kPrimary), + rightChevronIcon: + Icon(Icons.chevron_right, color: _kPrimary), + ), + daysOfWeekStyle: const DaysOfWeekStyle( + weekdayStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + weekendStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFEF4444), ), ), ), - ], - ); - }, + ), + ), + ), ); } - ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional userProfessional) { - switch (numDay) { - case 1: - return userProfessional.schedules.monday; - case 2: - return userProfessional.schedules.tuesday; - case 3: - return userProfessional.schedules.wednesday; - case 4: - return userProfessional.schedules.thursday; - case 5: - return userProfessional.schedules.friday; - case 6: - return userProfessional.schedules.saturday; - case 7: - return userProfessional.schedules.sunday; - default: - return null; - } + // ── Day header + stats ──────────────────────────────────────────────────── + + Widget _dayHeader( + ScheduleEntity? schedule, int total, int occupied) { + final dayName = DateFormat('EEEE', 'es').format(_selected); + final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected); + final available = total - occupied; + final hasSchedule = schedule != null && schedule.enabled; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 12, + offset: const Offset(0, 2), + ) + ], + ), + child: Row( + children: [ + // Date block + Container( + width: 48, + height: 52, + decoration: BoxDecoration( + color: _kPrimary, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('d').format(_selected), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + Text( + DateFormat('MMM', 'es').format(_selected).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: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + Text( + dateStr, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + ), + if (hasSchedule && total > 0) ...[ + _StatPill( + label: '$occupied', + sublabel: 'ocupadas', + color: _kOccupied, + ), + const SizedBox(width: 8), + _StatPill( + label: '$available', + sublabel: 'libres', + color: _kAvailable, + ), + ], + ], + ), + ), + ), + ); } - List _rangesItems(ScheduleEntity? schedule, BuildContext context) { - if (schedule == null || - !schedule.enabled || - schedule.range1Hour1 == null || - schedule.range2Hour2 == null) { - return [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 25), - child: Text("No hay horarios disponibles"), - ) - ]; - } + // ── Slot cards ──────────────────────────────────────────────────────────── - if (schedule.continuousDay) { - List ranges = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range2Hour2!, + List _slotCards(List slots, ScheduleEntity? schedule) { + return slots.map((time) { + final occupied = _isOccupied(time, _services, _selected); + final matchService = occupied ? _serviceFor(time, _services, _selected) : null; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: IntrinsicHeight( + child: Row( + children: [ + // Color accent strip + Container( + width: 4, + color: occupied ? _kOccupied : _kAvailable, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + child: Row( + children: [ + // Time badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: (occupied ? _kOccupied : _kAvailable) + .withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + ScheduleEntity.getFormatTime(time) ?? '', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: + occupied ? _kOccupied : _kAvailable, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Text( + occupied ? 'Ocupado' : 'Disponible', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: occupied + ? _kOccupied + : _kAvailable, + ), + ), + if (matchService != null && + matchService.description.isNotEmpty) + Text( + matchService.description, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else if (!occupied) + const Text( + 'Horario libre para nuevas citas', + style: TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + ), + // Status icon + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: (occupied ? _kOccupied : _kAvailable) + .withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + occupied + ? Icons.event_busy_outlined + : Icons.event_available_outlined, + size: 17, + color: + occupied ? _kOccupied : _kAvailable, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), ); + }).toList(); + } - return rangesItemList(ranges, _services, today, context); + // ── Empty state ─────────────────────────────────────────────────────────── + + Widget _emptyState() { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 12, + offset: const Offset(0, 2), + ) + ], + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF94A3B8).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.event_busy_outlined, + size: 32, color: Color(0xFF94A3B8)), + ), + const SizedBox(height: 16), + const Text( + 'Sin horario este día', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 6), + const Text( + 'No tienes horario de atención configurado\npara este día de la semana.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Color(0xFF94A3B8), + height: 1.5, + ), + ), + ], + ), + ), + ), + ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + ScheduleEntity? _scheduleFor(int weekday, Profesional pro) { + return switch (weekday) { + 1 => pro.schedules.monday, + 2 => pro.schedules.tuesday, + 3 => pro.schedules.wednesday, + 4 => pro.schedules.thursday, + 5 => pro.schedules.friday, + 6 => pro.schedules.saturday, + _ => pro.schedules.sunday, + }; + } + + List _buildSlots(ScheduleEntity? s) { + if (s == null || + !s.enabled || + s.range1Hour1 == null || + s.range2Hour2 == null) return []; + if (s.continuousDay) { + return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!); } - - List ranges1 = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range1Hour2!, - ); - List ranges2 = TimeOfDayUtils.genRanges( - schedule.range2Hour1!, - schedule.range2Hour2!, - ); - + if (s.range1Hour2 == null || s.range2Hour1 == null) return []; return [ - ...rangesItemList(ranges1, _services, today, context), - ...rangesItemList(ranges2, _services, today, context), + ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!), + ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!), ]; } - bool _isHora1Ocupada( - TimeOfDay hora1, List? events, DateTime selectedDay) { - if (events != null) { - for (Service event in events) { - if (selectedDay.toIso8601String().split('T').first == event.day) { - if (hora1 == event.range1Hour1) { - return true; - } - } - } - } - return false; + bool _isOccupied( + TimeOfDay time, List? services, DateTime day) { + if (services == null) return false; + final dayStr = day.toIso8601String().split('T').first; + return services + .any((s) => s.day == dayStr && s.range1Hour1 == time); } - List rangesItemList(List ranges, List? events, - DateTime selectedDay, BuildContext context) { - return ranges.map((time) { - if (_isHora1Ocupada(time, events, selectedDay)) { - return Card( - elevation: 4, - margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - onTap: () { - if (events != null) { - for (Service event in events) { - if (selectedDay.toIso8601String().split('T').first == - event.day) { - if (time == event.range1Hour1) { - if (event.userId == event.professionalId) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Horario ocupado por ti'), - ), - ); - } else { - // Navigator.push( - // context, - // CupertinoPageRoute( - // builder: (context) => ProfessionalServiceScreen( - // serviceId: event.id!, - // ), - // ), - // ); - } - } - } - } - } - }, - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.yellow, Colors.red, Colors.red], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.access_time, - color: Colors.white, - ), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), - ), - subtitle: const Text( - 'Ocupado', - style: TextStyle( - color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold), - ), - ), - ); - } else { - return Card( - elevation: 4, - margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - onTap: () { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: const Text('Reservar hora'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '¿Estás seguro de que deseas 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('No, cancelar'), - ), - TextButton( - onPressed: () { - Navigator.pop(dialogContext); + Service? _serviceFor( + TimeOfDay time, List? services, DateTime day) { + if (services == null) return null; + final dayStr = day.toIso8601String().split('T').first; + try { + return services + .firstWhere((s) => s.day == dayStr && s.range1Hour1 == time); + } catch (_) { + return null; + } + } - Navigator.pop(context); - }, - child: const Text('Sí, reservar'), - ), - ], - ); - }, - ); - }, - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue, Colors.green], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.access_time, - color: Colors.white, - ), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), - ), - subtitle: const Text( - 'Disponible', - style: TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), + String _capitalize(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); +} + +// ── Stat pill ───────────────────────────────────────────────────────────── + +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, ), ), - ); - } - }).toList(); + Text( + sublabel, + style: TextStyle( + fontSize: 9, + color: color.withOpacity(0.8), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); } } diff --git a/lib/ui/views/services_requests_view.dart b/lib/ui/views/services_requests_view.dart index c29193c..d58a4a6 100644 --- a/lib/ui/views/services_requests_view.dart +++ b/lib/ui/views/services_requests_view.dart @@ -3,138 +3,375 @@ import 'package:intl/intl.dart'; import 'package:prosapp_web_app/models/schedules_entity.dart'; import 'package:prosapp_web_app/models/service.dart'; import 'package:prosapp_web_app/models/service_status.dart'; +import 'package:prosapp_web_app/models/servicio_profesional.dart'; import 'package:prosapp_web_app/services/navigation_service.dart'; -import 'package:prosapp_web_app/ui/labels/custom_labels.dart'; -import 'package:prosapp_web_app/ui/shared/widgets/status_item.dart'; - import 'package:provider/provider.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart'; -import 'package:prosapp_web_app/ui/cards/white_card.dart'; +const _kPrimary = Color(0xFF1565C0); +const _kBg = Color(0xFFF4F6FA); +const _kCard = Colors.white; class ServicesRequestsView extends StatelessWidget { const ServicesRequestsView({super.key}); @override Widget build(BuildContext context) { - final servicesProvider = - Provider.of(context, listen: false); - - servicesProvider.getServicesRequestsForProfessional( + final sp = Provider.of(context, listen: false); + sp.getServicesRequestsForProfessional( Provider.of(context, listen: false).user!.id); - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: Consumer( - builder: (context, servicesProvider, child) { - if (servicesProvider.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } + return Container( + color: _kBg, + child: Consumer( + builder: (context, sp, _) { + if (sp.isLoading) { + return const Center( + child: CircularProgressIndicator(color: _kPrimary), + ); + } - if (servicesProvider.services.isEmpty) { - return ListView( - children: const [ - WhiteCard( - child: Center(child: Text('No hay servicios disponibles.')), - ), - ], - ); - } - - return ListView.builder( - itemCount: servicesProvider.services.length, - itemBuilder: (context, index) { - final data = servicesProvider.services[index]; - - final image = - (data.user.picture == '' || data.user.picture == null) - ? const Image(image: AssetImage('no-image.jpg')) - : FadeInImage.assetNetwork( - placeholder: 'loader.gif', - fit: BoxFit.cover, - image: data.user.picture!, - ); - - return Container( - margin: const EdgeInsets.only(bottom: 10), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - NavigationService.replaceTo( - '/dashboard/professional/service/${data.service.id}'); - }, - child: WhiteCard( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 10), - child: SizedBox( - width: 80, - height: 80, - child: ClipOval( - child: image, - ), - ), - ), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - data.user.name, - style: CustomLabels.h2, - ), - if (data.service.description != '') - Text( - '"${data.service.description}"', - style: CustomLabels.h5, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(right: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${ScheduleEntity.getFormatTime(data.service.range1Hour1)} - ${DateFormat('dd MMMM yyyy', 'es').format(DateTime.parse(data.service.day))}', - style: const TextStyle( - color: Colors.black54, fontSize: 16), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 10), - child: customStatus(data.service), - ), - ], - ), - ), - ], + return ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 32), + children: [ + // Header + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.inbox_outlined, + size: 19, color: _kPrimary), ), - ), + const SizedBox(width: 10), + const Text( + 'Solicitudes', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1E293B), + ), + ), + const Spacer(), + if (sp.services.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${sp.services.length}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: _kPrimary, + ), + ), + ), + ], ), ), - ); - }, - ); - }, + ), + ), + + if (sp.services.isEmpty) + _emptyState() + else + ...sp.services.map((data) => _RequestCard(data: data)), + ], + ); + }, + ), + ); + } + + Widget _emptyState() { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 12, + offset: const Offset(0, 2), + ) + ], + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF94A3B8).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.inbox_outlined, + size: 32, color: Color(0xFF94A3B8)), + ), + const SizedBox(height: 16), + const Text( + 'Sin solicitudes pendientes', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 6), + const Text( + 'Cuando un cliente solicite tus servicios\naparecerá aquí.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Color(0xFF94A3B8), + height: 1.5, + ), + ), + ], + ), ), ), ); } } -Widget customStatus(Service service) { - if (service.status == ServiceStatus.pending) { - return const StatusItem(text: 'Solicitud', color: Colors.black45); +class _RequestCard extends StatelessWidget { + final ServicioProfesional data; + const _RequestCard({required this.data}); + + @override + Widget build(BuildContext context) { + final service = data.service; + final user = data.user; + final statusInfo = _statusInfo(service.status); + final dateStr = DateFormat('dd MMM yyyy', 'es') + .format(DateTime.parse(service.day)); + final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? ''; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + decoration: BoxDecoration( + color: _kCard, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => NavigationService.replaceTo( + '/dashboard/professional/service/${service.id}'), + child: IntrinsicHeight( + child: Row( + children: [ + // Status accent strip + Container(width: 4, color: statusInfo.color), + // Avatar + Padding( + padding: const EdgeInsets.all(14), + child: Stack( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: statusInfo.color.withOpacity(0.3), + width: 2, + ), + ), + child: ClipOval( + child: (user.picture == null || + user.picture!.isEmpty) + ? Container( + color: _kPrimary.withOpacity(0.1), + child: Center( + child: Text( + user.name.isNotEmpty + ? user.name[0].toUpperCase() + : '?', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: _kPrimary, + ), + ), + ), + ) + : FadeInImage.assetNetwork( + placeholder: 'loader.gif', + image: user.picture!, + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ), + // Content + Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + user.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + if (service.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + '"${service.description}"', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.calendar_today_outlined, + size: 12, + color: Color(0xFF94A3B8)), + const SizedBox(width: 4), + Text( + '$dateStr · $timeStr', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ), + // Status + arrow + Padding( + padding: const EdgeInsets.fromLTRB(8, 14, 12, 14), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _StatusBadge(info: statusInfo), + const SizedBox(height: 8), + Icon(Icons.chevron_right, + size: 18, + color: Colors.grey.shade400), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); } - return const SizedBox(); +} + +class _StatusBadge extends StatelessWidget { + final _StatusInfo info; + const _StatusBadge({required this.info}); + + @override + Widget build(BuildContext context) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: info.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: info.color.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(info.icon, size: 11, color: info.color), + const SizedBox(width: 4), + Text( + info.label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: info.color, + ), + ), + ], + ), + ); + } +} + +class _StatusInfo { + final String label; + final Color color; + final IconData icon; + const _StatusInfo(this.label, this.color, this.icon); +} + +_StatusInfo _statusInfo(ServiceStatus status) { + return switch (status) { + ServiceStatus.pending => + const _StatusInfo('Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined), + ServiceStatus.acepted => + const _StatusInfo('Aceptado', Color(0xFF1565C0), Icons.check_circle_outline), + ServiceStatus.active => + const _StatusInfo('En curso', Color(0xFF16A34A), Icons.play_circle_outline), + ServiceStatus.completed => + const _StatusInfo('Completado', Color(0xFF64748B), Icons.task_alt_outlined), + ServiceStatus.denied => + const _StatusInfo('Rechazado', Color(0xFFDC2626), Icons.cancel_outlined), + ServiceStatus.cancelled => + const _StatusInfo('Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline), + ServiceStatus.selfBooked => + const _StatusInfo('Reservado', Color(0xFF7C3AED), Icons.bookmark_outline), + }; }