diff --git a/lib/screens/lists/professional_pending_service_list.dart b/lib/screens/lists/professional_pending_service_list.dart index 7d23aea..3567871 100644 --- a/lib/screens/lists/professional_pending_service_list.dart +++ b/lib/screens/lists/professional_pending_service_list.dart @@ -1,5 +1,3 @@ -import 'package:user_repository/user_repository.dart'; - import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,6 +8,20 @@ import 'package:prosappco/blocs/service_bloc/service_bloc.dart'; import 'package:prosappco/screens/service/professional_service_screen.dart'; import 'package:service_repository/service_repository.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:user_repository/user_repository.dart'; + +const _kPrimary = Color(0xFF1565C0); + +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 shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04); +} class ProfessionalPendingServiceListScreen extends StatefulWidget { const ProfessionalPendingServiceListScreen({super.key}); @@ -26,10 +38,9 @@ class _ProfessionalPendingServiceListScreenState @override void initState() { super.initState(); - serviceBloc = Injector.appInstance.get(); - - serviceBloc.add(LoadPendingServicesForProfessional(ApiUserRepository.currentUserId ?? '')); + serviceBloc.add( + LoadPendingServicesForProfessional(ApiUserRepository.currentUserId ?? '')); } @override @@ -37,180 +48,247 @@ class _ProfessionalPendingServiceListScreenState return BlocProvider( create: (context) => serviceBloc, child: Scaffold( + backgroundColor: context.bg, + appBar: AppBar( + title: const Text('Solicitudes'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, + ), body: BlocBuilder( builder: (context, serviceState) { if (serviceState is ServicesForUserLoaded) { - return serviceState.services.isEmpty - ? const Center( - child: Text('No tienes solicitudes'), - ) - : ListView.builder( - itemCount: serviceState.services.length, - itemBuilder: (_, index) { - final serviceInfo = serviceState.services[index]; - final user = serviceInfo.user; - final service = serviceInfo.service; - - return Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.withOpacity(0.2)), - ), - ), - child: ListTile( - onTap: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - ProfessionalServiceScreen( - serviceId: service.id!, - ), - ), - ); - }, - leading: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - image: user.picture == null - ? null - : DecorationImage( - image: NetworkImage(user.picture!), - fit: BoxFit.contain, - ), - ), - child: user.picture == null - ? Icon( - CupertinoIcons.person, - color: Colors.grey.shade400, - size: 40, - ) - : null, - ), - title: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Text( - '${user.name}', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 5), - Text( - '${DateFormat('dd MMMM', 'es').format(DateTime.parse(service.day))} - ${ScheduleEntity.getFormatTime(service.range1Hour1)}', - style: TextStyle( - color: Colors.grey[600], - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text( - 'Estado:', - style: TextStyle( - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(width: 5), - customStatus(service), - ], - ), - service.description.isEmpty - ? Container() - : Text( - '"${service.description.trim()}"', - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ); - }, - ); + if (serviceState.services.isEmpty) return _emptyState(context); + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 12), + itemCount: serviceState.services.length, + itemBuilder: (_, index) => + _RequestCard(info: serviceState.services[index]), + ); } - - return Shimmer.fromColors( - baseColor: Colors.grey[300]!, - highlightColor: Colors.grey[100]!, - child: ListView.builder( - itemCount: 10, - itemBuilder: (_, __) => ListTile( - leading: CircleAvatar( - backgroundColor: Colors.grey[300], - radius: 30, - ), - title: Container( - height: 20, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), - ), - ), - subtitle: Container( - height: 15, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ); + return _shimmerList(); }, ), ), ); } - Container customStatus(ServiceEntity service) { - if (service.status == ServiceStatus.pending) { - return itemStatus(Colors.black54, 'Pendiente'); - } - if (service.status == ServiceStatus.acepted) { - return itemStatus(Colors.green, 'Aceptado'); - } - if (service.status == ServiceStatus.active) { - return itemStatus(Colors.blueAccent, 'Activo'); - } - - return itemStatus(Colors.red, 'Cancelado'); + Widget _emptyState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: context.subtle.withOpacity(0.1), shape: BoxShape.circle), + child: + Icon(Icons.inbox_outlined, size: 36, color: context.subtle), + ), + const SizedBox(height: 16), + Text('Sin solicitudes pendientes', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: context.muted)), + const SizedBox(height: 6), + Text('Cuando un cliente solicite tus servicios\naparecerá aquí.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: context.subtle, height: 1.5)), + ]), + ), + ); } - Container itemStatus(Color color, String text) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - text, - style: const TextStyle( - fontStyle: FontStyle.italic, - color: Colors.white, + Widget _shimmerList() { + return Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 12), + itemCount: 8, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + height: 84, + decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(14)), ), ), ); } } -// pending - 0 -// acepted - 1 -// active - 2 -// cancelled - 3 -// completed - 4 \ No newline at end of file +class _RequestCard extends StatelessWidget { + final ServiceInfoUI info; + const _RequestCard({required this.info}); + + @override + Widget build(BuildContext context) { + final service = info.service; + final user = info.user; + final si = _statusInfo(service.status); + final dateStr = DateFormat('dd MMM yyyy', 'es') + .format(DateTime.parse(service.day)); + final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? ''; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 10, + offset: const Offset(0, 2)) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + ProfessionalServiceScreen(serviceId: service.id!)), + ), + child: IntrinsicHeight( + child: Row(children: [ + Container(width: 4, color: si.color), + Padding( + padding: const EdgeInsets.all(12), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: si.color.withOpacity(0.35), width: 2)), + child: ClipOval( + child: (user.picture == null || user.picture!.isEmpty) + ? Container( + color: _kPrimary.withOpacity(0.1), + child: Center( + child: Text( + user.name?.isNotEmpty == true + ? user.name![0].toUpperCase() + : '?', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: _kPrimary), + ), + ), + ) + : Image.network(user.picture!, fit: BoxFit.cover), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(user.name ?? '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: context.onSurface)), + if (service.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text('"${service.description}"', + style: TextStyle( + fontSize: 12, + color: context.muted, + fontStyle: FontStyle.italic), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 5), + Row(children: [ + Icon(Icons.calendar_today_outlined, + size: 11, color: context.subtle), + const SizedBox(width: 4), + Text('$dateStr · $timeStr', + style: TextStyle( + fontSize: 11, + color: context.muted, + fontWeight: FontWeight.w500)), + ]), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 12, 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _StatusBadge(si: si), + const SizedBox(height: 6), + Icon(Icons.chevron_right, size: 18, color: context.subtle), + ], + ), + ), + ]), + ), + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + final _StatusInfo si; + const _StatusBadge({required this.si}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: si.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: si.color.withOpacity(0.3)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(si.icon, size: 10, color: si.color), + const SizedBox(width: 4), + Text(si.label, + style: TextStyle( + fontSize: 10, fontWeight: FontWeight.w700, color: si.color)), + ]), + ); + } +} + +class _StatusInfo { + final String label; + final Color color; + final IconData icon; + const _StatusInfo(this.label, this.color, this.icon); +} + +_StatusInfo _statusInfo(ServiceStatus status) { + switch (status) { + case ServiceStatus.acepted: + return const _StatusInfo('Aceptado', Color(0xFF1565C0), Icons.check_circle_outline); + case ServiceStatus.active: + return const _StatusInfo('En curso', Color(0xFF16A34A), Icons.play_circle_outline); + case ServiceStatus.completed: + return const _StatusInfo('Completado', Color(0xFF64748B), Icons.task_alt_outlined); + case ServiceStatus.denied: + return const _StatusInfo('Rechazado', Color(0xFFDC2626), Icons.cancel_outlined); + case ServiceStatus.cancelled: + return const _StatusInfo('Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline); + case ServiceStatus.selfBooked: + return const _StatusInfo('Reservado', Color(0xFF7C3AED), Icons.bookmark_outline); + default: + return const _StatusInfo('Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined); + } +} diff --git a/lib/screens/professional/professional_calendar_screen.dart b/lib/screens/professional/professional_calendar_screen.dart index dc8c955..8d8c02e 100644 --- a/lib/screens/professional/professional_calendar_screen.dart +++ b/lib/screens/professional/professional_calendar_screen.dart @@ -1,22 +1,33 @@ import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.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:provider/provider.dart'; -import 'package:table_calendar/table_calendar.dart'; -import 'package:injector/injector.dart'; -import 'package:professional_repository/professional_repository.dart'; -import 'package:intl/intl.dart'; import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:service_repository/service_repository.dart'; -import 'package:setting_repository/setting_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 @@ -24,49 +35,28 @@ class ProfessionalCalendarScreen extends StatefulWidget { _ProfessionalCalendarScreenState(); } -class _ProfessionalCalendarScreenState extends State { - final settingRepository = Injector.appInstance.get(); - final serviceRepository = - Injector.appInstance.get(); - SettingEntity? settings; +class _ProfessionalCalendarScreenState + extends State { + final serviceRepository = Injector.appInstance.get(); DateTime today = DateTime.now(); - DateTime now = DateTime.now(); late int numDay; - List? _services; - CalendarFormat _calendarFormat = CalendarFormat.month; - bool isLoading = false; @override void initState() { super.initState(); - today = DateTime.utc(today.year, today.month, today.day); numDay = today.weekday; - - _loadSettings(); _loadServices(); } - void _loadSettings() { - settingRepository.getSettings().then( - (value) => setState(() { - settings = value; - }), - ); - } - void _loadServices() { serviceRepository .getServicesForProfessionalforCalendar(widget.userProfessional.id) - .then((services) { - setState(() { - _services = services; - }); - }); + .then((services) => setState(() => _services = services)); } void _onDaySelected(DateTime day, DateTime focusedDay) { @@ -76,86 +66,39 @@ class _ProfessionalCalendarScreenState extends State }); } - void _onFormatChange(CalendarFormat format) { - setState(() { - _calendarFormat = format; - }); - } - @override Widget build(BuildContext context) { - DateTime lastDay = today.add(const Duration(days: 365)); + 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, ), - floatingActionButtonLocation: kIsWeb - ? FloatingActionButtonLocation.startFloat - : FloatingActionButtonLocation.endFloat, - resizeToAvoidBottomInset: false, body: BlocProvider( create: (context) => Injector.appInstance.get(), child: BlocConsumer( - listener: (context, serviceState) { - if (serviceState is CreateServiceLoading) { - isLoading = true; - } - if (serviceState is CreateServiceFailure) { - isLoading = false; - } + listener: (context, state) { + if (state is CreateServiceLoading) isLoading = true; + if (state is CreateServiceFailure) isLoading = false; }, builder: (context, state) { - return Column( + return ListView( + padding: const EdgeInsets.only(bottom: 32), children: [ - Container( - color: const Color.fromARGB(255, 224, 247, 255), - child: TableCalendar( - locale: 'es_MX', - firstDay: DateTime.now(), - lastDay: lastDay, - focusedDay: today, - availableGestures: AvailableGestures.all, - onDaySelected: _onDaySelected, - selectedDayPredicate: (day) => isSameDay(day, today), - calendarFormat: _calendarFormat, - onFormatChanged: _onFormatChange, - availableCalendarFormats: const { - CalendarFormat.month: 'Mes', - CalendarFormat.week: 'Semana', - CalendarFormat.twoWeeks: '2 Semanas', - }, - ), - ), - 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, - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 15), - child: Column( - children: [ - ..._rangesItems( - _getScheduleFromNumDay(numDay), context), - ], - ), - ), - ) + _calendarCard(context), + _dayHeader(context, schedule, slots.length, occupied, available), + if (slots.isEmpty) + _emptyState(context) + else + ..._slotCards(context, slots, state), ], ); }, @@ -164,244 +107,400 @@ class _ProfessionalCalendarScreenState extends State ); } - ScheduleEntity? _getScheduleFromNumDay(int numDay) { - switch (numDay) { - 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; - case 7: - return widget.userProfessional.schedules.sunday; - default: - return null; + 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) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Horario ocupado por ti'))); + } else { + Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + ProfessionalServiceScreen(serviceId: event.id!)), + ); + } + return; + } } } - List _rangesItems(ScheduleEntity? schedule, BuildContext context) { - if (schedule == null || - schedule.enabled == false || - schedule.range1Hour1 == null || - schedule.range2Hour2 == null) { - return [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 25), - child: Text("No hay horarios disponibles"), - ) - ]; - } - - if (schedule.continuousDay) { - List ranges = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range2Hour2!, - ); - - return rangesItemList(ranges, _services, today, context); - } - - List ranges1 = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range1Hour2!, - ); - List ranges2 = TimeOfDayUtils.genRanges( - schedule.range2Hour1!, - schedule.range2Hour2!, + 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.replacing(hour: time.hour + 2), + rate: '0', + location: ServiceLocationPreferences.office, + status: ServiceStatus.selfBooked, + )); + }, + child: const Text('Reservar'), + ), + ], + ), ); + } + 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 []; + } + if (s.continuousDay) { + return TimeOfDayUtils.genRanges(s.range1Hour1!, s.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 (ServiceEntity event in events) { - if (selectedDay.toString() == event.day) { - if (hora1 == event.range1Hour1) { - return true; - } - } - } - } - return false; + bool _isOccupied( + TimeOfDay time, List? services, DateTime day) { + if (services == null) return false; + return services + .any((s) => day.toString() == s.day && 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 (ServiceEntity event in events) { - if (selectedDay.toString() == 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 accion 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); + String _capitalize(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); +} - Navigator.pop(context); +class _StatPill extends StatelessWidget { + final String label; + final String sublabel; + final Color color; + const _StatPill( + {required this.label, required this.sublabel, required this.color}); - 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(hour: 2), - rate: '0', - location: ServiceLocationPreferences.office, - status: ServiceStatus.selfBooked, - ), - ); - }, - child: const Text('Si, 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, - ), - ), - ), - ); - } - }).toList(); + @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)), + ]), + ); } } diff --git a/lib/screens/professional/professional_profile_screen.dart b/lib/screens/professional/professional_profile_screen.dart index 564936d..73728d3 100644 --- a/lib/screens/professional/professional_profile_screen.dart +++ b/lib/screens/professional/professional_profile_screen.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:injector/injector.dart'; @@ -13,6 +12,21 @@ import 'package:prosappco/screens/professional/professional_map_screen.dart'; import 'package:prosappco/screens/professional/professional_schedule_screen.dart'; import 'package:setting_repository/setting_repository.dart'; +const _kPrimary = Color(0xFF1565C0); +const _kAccent = Color(0xFF42A4EF); + +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 shadowSm => + isDark ? Colors.transparent : Colors.black.withOpacity(0.05); +} + class ProfessionalProfileScreen extends StatefulWidget { const ProfessionalProfileScreen({super.key}); @@ -21,21 +35,20 @@ class ProfessionalProfileScreen extends StatefulWidget { _ProfessionalProfileScreenState(); } -class _ProfessionalProfileScreenState extends State { +class _ProfessionalProfileScreenState + extends State { final settingRepository = Injector.appInstance.get(); SettingEntity? settings; XFile? _imageFile; bool loadFinish = false; - bool officeValue = false; bool deliveryValue = false; bool rateValue = false; - final TextEditingController _addressController = TextEditingController(); - final TextEditingController _aditionalAddressController = - TextEditingController(); - final TextEditingController _rateController = TextEditingController(); + final _addressController = TextEditingController(); + final _aditionalAddressController = TextEditingController(); + final _rateController = TextEditingController(); Schedules schedules = Schedules.empty; bool isInit = false; @@ -50,8 +63,9 @@ class _ProfessionalProfileScreenState extends State { @override void initState() { super.initState(); - - _loadSettings(); + settingRepository.getSettings().then( + (v) => setState(() => settings = v), + ); } @override @@ -59,70 +73,50 @@ class _ProfessionalProfileScreenState extends State { _addressController.dispose(); _aditionalAddressController.dispose(); _rateController.dispose(); - super.dispose(); } - void _loadSettings() { - settingRepository.getSettings().then( - (value) => setState(() { - settings = value; - }), - ); - } - @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: context.bg, appBar: AppBar( title: const Text('Perfil Profesional'), + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, ), body: BlocBuilder( builder: (context, state) { - return Scaffold( - body: SingleChildScrollView( - child: content(context, state), - ), - ); + if (state is LoadedModeProState) { + if (state.isProModeActive) { + if (state.proInfo == null) { + return const Center(child: CircularProgressIndicator(color: _kPrimary)); + } + if (!isInit) { + schedules = state.proInfo!.schedules; + isInit = true; + } + return SingleChildScrollView( + child: _body(context, state.proInfo!), + ); + } + } + return const Center(child: Text('Vuelve atrás')); }, ), ); } - content(BuildContext context, ProfessionalState state) { - if (state is LoadedModeProState) { - if (state.isProModeActive) { - if (state.proInfo == null) { - return const Text('Loading...'); - } else { - if (!isInit) { - schedules = state.proInfo!.schedules; - isInit = true; - } - - return body(state.proInfo!); - } - } - } - return const Text('Go back'); - } - - body(ProfessionalEntity proInfo) { + Widget _body(BuildContext context, ProfessionalEntity proInfo) { if (!loadFinish) { _addressController.text = proInfo.address; _aditionalAddressController.text = proInfo.aditionalAddress; - _longitudeController = proInfo.longitude; _latitudeController = proInfo.latitude; - isNequiActive = proInfo.paymentMethods.nequi; isDatafonoActive = proInfo.paymentMethods.datafono; isTransferActive = proInfo.paymentMethods.transferencia; - - // proInfo.bannerPicture != '' - // ? _imageFile = XFile(proInfo.bannerPicture) - // : _imageFile = null; - if (proInfo.locationPreferences == LocationPreferences.both) { officeValue = true; deliveryValue = true; @@ -133,499 +127,526 @@ class _ProfessionalProfileScreenState extends State { officeValue = false; deliveryValue = true; } - _rateController.text = proInfo.rate; - loadFinish = true; } return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - pictureWidget(proInfo.bannerPicture, context), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 15), - child: Column( - children: [ - Visibility( - visible: settings?.domicilios ?? false, - child: Row( - children: [ - const Expanded( - child: Text( - 'Servicio a domicilio', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - ), - Switch( - value: deliveryValue, - onChanged: (value) { - setState(() { - deliveryValue = value; - if (settings?.domicilios == true) { - deliveryValue = value; - if (!deliveryValue) { - officeValue = true; - } - } else { - deliveryValue = false; - } - }); - }, - ), - ], - ), + _bannerHeader(context, proInfo), + const SizedBox(height: 16), + _sectionCard( + context, + icon: Icons.place_outlined, + title: 'Modalidad de servicio', + child: Column(children: [ + if (settings?.domicilios ?? false) + _toggleRow( + context, + icon: Icons.delivery_dining_outlined, + title: 'Servicio a domicilio', + subtitle: 'El profesional se desplaza al cliente', + value: deliveryValue, + onChanged: (v) => setState(() { + deliveryValue = v; + if (!deliveryValue) officeValue = true; + }), ), - Row( - children: [ - const Expanded( - child: Text( - 'Servicio en sitio', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - ), - Switch( - value: officeValue, - onChanged: (value) { - setState(() { - if (settings?.domicilios == true) { - officeValue = value; - if (!officeValue) { - deliveryValue = true; - } - } else { - officeValue = true; - deliveryValue = false; - } - }); - }, - ), - ], - ), - officeValue - ? Column( - children: [ - TextField( - decoration: const InputDecoration( - prefixIcon: Icon(Icons.location_on_outlined), - hintText: 'Dirección', - ), - controller: _addressController, - readOnly: true, - onTap: () async { - final Map? mapInfo = - await Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => - const ProfessionalMapScreen()), - ); - - if (mapInfo != null) { - setState(() { - _addressController.text = - mapInfo['address'] ?? ''; - _latitudeController = - mapInfo['latitude'] ?? 0; - _longitudeController = - mapInfo['longitude'] ?? 0; - }); - } - }), - TextField( - controller: _aditionalAddressController, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.house_outlined), - hintText: 'Piso / Conjunto / Apartamento', - ), - ), - ], - ) - .animate() - .moveY(duration: const Duration(milliseconds: 100)) - : const SizedBox(), - ], - ), - ), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 15), - child: Column( - children: [ - Row( - children: [ - const Expanded( - child: Text( - 'Tarifa', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - ), - Switch( - value: rateValue, - onChanged: (value) { - setState(() { - if (settings?.tarifas == true) { - rateValue = value; - } else { - rateValue = false; - } - }); - }, - ), - ], - ), - rateValue - ? TextField( - controller: _rateController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.attach_money), - hintText: 'COP', - ), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ) - .animate() - .moveY(duration: const Duration(milliseconds: 100)) - : const SizedBox(), - ], - ), - ), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), - child: Column( - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 10), - child: Text( - 'Metodos de pago', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - ), - CheckboxListTile( - value: isDatafonoActive, - onChanged: (value) { - setState(() { - isDatafonoActive = value!; - }); - }, - title: const Text('Datafono'), - ), - CheckboxListTile( - value: isNequiActive, - onChanged: (value) { - setState(() { - isNequiActive = value!; - }); - }, - title: const Text('Nequi'), - ), - CheckboxListTile( - value: isTransferActive, - onChanged: (value) { - setState(() { - isTransferActive = value!; - }); - }, - title: const Text('Transferencia bancaria'), - ), - ], - ), - ), - const Divider(height: 0), - GestureDetector( - onTap: () async { - Schedules? schedules = await Navigator.push( + _toggleRow( context, - CupertinoPageRoute( - builder: (context) => - ProfessionalScheduleScreen(schedules: this.schedules), - ), - ); - - if (schedules != null) { - setState(() { - this.schedules = schedules; - }); - } - }, - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - children: [ - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Lunes'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.monday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Martes'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.tuesday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Miercoles'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.wednesday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Jueves'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.thursday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Viernes'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.friday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Sabado'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.saturday, context)), - ), - ), - ], - ), - TableRow( - children: [ - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: const Text('Domingo'), - ), - ), - TableCell( - child: Container( - padding: const EdgeInsets.all(8.0), - child: Text(timeList(schedules.sunday, context)), - ), - ), - ], - ), - ], - ), - ), - const Divider( - height: 1, - thickness: 0.5, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 15, - ), - child: ElevatedButton( - onPressed: () { - context.read().add( - UpdateProfessionalProfileInfo( - address: _addressController.text, - aditionalAddress: _aditionalAddressController.text, - latitude: _latitudeController, - longitude: _longitudeController, - locationPreferences: officeValue && deliveryValue - ? LocationPreferences.both - : officeValue && !deliveryValue - ? LocationPreferences.office - : deliveryValue && !officeValue - ? LocationPreferences.delivery - : LocationPreferences.office, - paymentMethods: PaymentMethodEntity( - datafono: isDatafonoActive, - nequi: isNequiActive, - transferencia: isTransferActive, - ), - schedules: schedules, - ratePreferences: rateValue, - rate: _rateController.text, - ), - ); - context.read().add( - UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path), - ); - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Información actualizada correctamente'), - )); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + icon: Icons.storefront_outlined, + title: 'Servicio en sitio', + subtitle: 'El cliente asiste al lugar del profesional', + value: officeValue, + onChanged: (v) => setState(() { + if (settings?.domicilios == true) { + officeValue = v; + if (!officeValue) deliveryValue = true; + } else { + officeValue = true; + deliveryValue = false; + } + }), ), - child: Container( - alignment: Alignment.center, - child: const Text( - 'Guardar', - style: TextStyle( - color: Colors.white, - fontSize: 18, + if (officeValue) ...[ + const SizedBox(height: 8), + _addressField(context), + ], + ]), + ), + const SizedBox(height: 12), + _sectionCard( + context, + icon: Icons.attach_money_outlined, + title: 'Tarifa', + child: Column(children: [ + _toggleRow( + context, + icon: Icons.monetization_on_outlined, + title: 'Mostrar tarifa', + subtitle: 'Visible para los clientes', + value: rateValue, + onChanged: (v) => setState(() { + rateValue = (settings?.tarifas == true) ? v : false; + }), + ), + if (rateValue) ...[ + const SizedBox(height: 8), + TextField( + controller: _rateController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + prefixIcon: + Icon(Icons.attach_money, color: context.muted), + hintText: 'Valor en COP', + filled: true, + fillColor: context.isDark + ? Colors.white.withOpacity(0.05) + : Colors.black.withOpacity(0.04), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), ), ), + ], + ]), + ), + const SizedBox(height: 12), + _sectionCard( + context, + icon: Icons.credit_card_outlined, + title: 'Métodos de pago', + child: Wrap(spacing: 8, runSpacing: 8, children: [ + _PayChip( + label: 'Datafono', + icon: Icons.credit_card, + active: isDatafonoActive, + onTap: () => setState(() => isDatafonoActive = !isDatafonoActive), + ), + _PayChip( + label: 'Nequi', + icon: Icons.phone_android_outlined, + active: isNequiActive, + onTap: () => setState(() => isNequiActive = !isNequiActive), + ), + _PayChip( + label: 'Transferencia', + icon: Icons.swap_horiz, + active: isTransferActive, + onTap: () => + setState(() => isTransferActive = !isTransferActive), + ), + ]), + ), + const SizedBox(height: 12), + _sectionCard( + context, + icon: Icons.calendar_month_outlined, + title: 'Horarios', + trailing: GestureDetector( + onTap: _openScheduleEditor, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Editar', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _kPrimary)), ), ), + child: _scheduleRows(context), ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ElevatedButton( + onPressed: _save, + style: ElevatedButton.styleFrom( + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: const Text('Guardar cambios', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ), + ), + const SizedBox(height: 32), ], ); } - Widget pictureWidget(String? bannerPicture, BuildContext context) { - final pictureUrl = bannerPicture; + Widget _bannerHeader(BuildContext context, ProfessionalEntity proInfo) { final pathImageFile = _imageFile?.path; - - ImageProvider? imageProvider; - + ImageProvider? bannerImage; if (pathImageFile != null && pathImageFile.isNotEmpty) { - imageProvider = FileImage(File(pathImageFile)); - } else if (pictureUrl != null && pictureUrl.isNotEmpty) { - imageProvider = NetworkImage(pictureUrl); + bannerImage = FileImage(File(pathImageFile)); + } else if (proInfo.bannerPicture.isNotEmpty) { + bannerImage = NetworkImage(proInfo.bannerPicture); } return GestureDetector( - onTap: () async { - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage( - source: ImageSource.gallery, - maxHeight: 1000, - maxWidth: 2000, - imageQuality: 40, - ); - - if (image != null) { - setState(() { - _imageFile = image; - }); - } - }, - child: pictureContainerWidget(imageProvider), - ); - } - - Widget pictureContainerWidget(ImageProvider? imageProvider) { - final image = imageProvider == null - ? null - : DecorationImage( - image: imageProvider, - fit: BoxFit.cover, - ); - - final widget = image == null - ? Icon( - Icons.image_outlined, - color: Colors.grey.shade400, - size: 40, - ) - : null; - - return Container( - width: double.infinity, - height: 200, - decoration: BoxDecoration( - color: Colors.grey.shade300, - image: image, + onTap: _pickBannerImage, + child: Container( + height: 180, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [_kPrimary, _kAccent], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + image: bannerImage != null + ? DecorationImage(image: bannerImage, fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.35), BlendMode.darken)) + : null, + ), + child: Stack(children: [ + Positioned( + bottom: 14, + right: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(20), + ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.camera_alt_outlined, color: Colors.white, size: 14), + SizedBox(width: 5), + Text('Cambiar banner', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600)), + ]), + ), + ), + if (proInfo.profession.isNotEmpty) + Positioned( + bottom: 14, + left: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white38), + ), + child: Text(proInfo.profession, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600)), + ), + ), + ]), ), - child: widget, ); } - String timeList(ScheduleEntity? schedule, BuildContext context) { - if (schedule == null) { - return 'No hay horarios'; - } - if (!schedule.enabled) { - return 'No hay horarios'; - } - if (schedule.range1Hour1 == null || schedule.range2Hour2 == null) { - return 'No hay horarios'; - } - if (schedule.continuousDay) { - return '${ScheduleEntity.getFormatTime(schedule.range1Hour1)} a ${ScheduleEntity.getFormatTime(schedule.range2Hour2)}'; - } else { - if (schedule.range1Hour2 == null || schedule.range2Hour1 == null) { - return 'No hay horarios'; - } + Widget _sectionCard( + BuildContext context, { + required IconData icon, + required String title, + required Widget child, + Widget? trailing, + }) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: context.card, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: context.shadowSm, + blurRadius: 10, + offset: const Offset(0, 2)) + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _kPrimary.withOpacity(0.08), + borderRadius: BorderRadius.circular(8)), + child: Icon(icon, size: 17, color: _kPrimary), + ), + const SizedBox(width: 10), + Expanded( + child: Text(title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: context.onSurface)), + ), + if (trailing != null) trailing, + ]), + const SizedBox(height: 14), + child, + ]), + ), + ); + } - return '${ScheduleEntity.getFormatTime(schedule.range1Hour1)} a ${ScheduleEntity.getFormatTime(schedule.range1Hour2)} - ${ScheduleEntity.getFormatTime(schedule.range2Hour1)} a ${ScheduleEntity.getFormatTime(schedule.range2Hour2)}'; + Widget _toggleRow( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Row(children: [ + Icon(icon, size: 18, color: context.muted), + const SizedBox(width: 10), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.onSurface)), + Text(subtitle, + style: TextStyle(fontSize: 11, color: context.muted)), + ]), + ), + Switch(value: value, onChanged: onChanged, activeColor: _kPrimary), + ]); + } + + Widget _addressField(BuildContext context) { + return Column(children: [ + GestureDetector( + onTap: () async { + final Map? mapInfo = await Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => const ProfessionalMapScreen()), + ); + if (mapInfo != null) { + setState(() { + _addressController.text = mapInfo['address'] ?? ''; + _latitudeController = mapInfo['latitude'] ?? 0; + _longitudeController = mapInfo['longitude'] ?? 0; + }); + } + }, + child: AbsorbPointer( + child: TextField( + controller: _addressController, + decoration: InputDecoration( + prefixIcon: + Icon(Icons.location_on_outlined, color: context.muted), + hintText: 'Dirección', + filled: true, + fillColor: context.isDark + ? Colors.white.withOpacity(0.05) + : Colors.black.withOpacity(0.04), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + ), + ), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _aditionalAddressController, + decoration: InputDecoration( + prefixIcon: Icon(Icons.house_outlined, color: context.muted), + hintText: 'Piso / Conjunto / Apartamento', + filled: true, + fillColor: context.isDark + ? Colors.white.withOpacity(0.05) + : Colors.black.withOpacity(0.04), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + ), + ), + ]); + } + + Widget _scheduleRows(BuildContext context) { + final days = <_DayEntry>[ + _DayEntry('Lunes', schedules.monday), + _DayEntry('Martes', schedules.tuesday), + _DayEntry('Miércoles', schedules.wednesday), + _DayEntry('Jueves', schedules.thursday), + _DayEntry('Viernes', schedules.friday), + _DayEntry('Sábado', schedules.saturday), + _DayEntry('Domingo', schedules.sunday), + ]; + + return Column( + children: days.map((entry) { + final name = entry.name; + final s = entry.schedule; + final active = s != null && s.enabled; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row(children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: active + ? const Color(0xFF16A34A) + : context.subtle, + shape: BoxShape.circle), + ), + const SizedBox(width: 10), + SizedBox( + width: 80, + child: Text(name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: context.onSurface)), + ), + Expanded( + child: Text( + _formatSchedule(s), + style: TextStyle(fontSize: 12, color: context.muted), + overflow: TextOverflow.ellipsis, + ), + ), + ]), + ); + }).toList(), + ); + } + + String _formatSchedule(ScheduleEntity? s) { + if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) { + return 'Sin horario'; } + if (s.continuousDay) { + return '${ScheduleEntity.getFormatTime(s.range1Hour1)} - ${ScheduleEntity.getFormatTime(s.range2Hour2)}'; + } + if (s.range1Hour2 == null || s.range2Hour1 == null) return 'Sin horario'; + return '${ScheduleEntity.getFormatTime(s.range1Hour1)} a ${ScheduleEntity.getFormatTime(s.range1Hour2)} · ' + '${ScheduleEntity.getFormatTime(s.range2Hour1)} a ${ScheduleEntity.getFormatTime(s.range2Hour2)}'; + } + + Future _pickBannerImage() async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + maxHeight: 1000, + maxWidth: 2000, + imageQuality: 40, + ); + if (image != null) setState(() => _imageFile = image); + } + + Future _openScheduleEditor() async { + final result = await Navigator.push( + context, + CupertinoPageRoute( + builder: (_) => + ProfessionalScheduleScreen(schedules: schedules)), + ); + if (result != null) setState(() => schedules = result); + } + + void _save() { + context.read().add(UpdateProfessionalProfileInfo( + address: _addressController.text, + aditionalAddress: _aditionalAddressController.text, + latitude: _latitudeController, + longitude: _longitudeController, + locationPreferences: officeValue && deliveryValue + ? LocationPreferences.both + : officeValue && !deliveryValue + ? LocationPreferences.office + : deliveryValue && !officeValue + ? LocationPreferences.delivery + : LocationPreferences.office, + paymentMethods: PaymentMethodEntity( + datafono: isDatafonoActive, + nequi: isNequiActive, + transferencia: isTransferActive, + ), + schedules: schedules, + ratePreferences: rateValue, + rate: _rateController.text, + )); + context.read().add( + UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path)); + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Información actualizada correctamente')), + ); + } +} + +class _DayEntry { + final String name; + final ScheduleEntity? schedule; + const _DayEntry(this.name, this.schedule); +} + +class _PayChip extends StatelessWidget { + final String label; + final IconData icon; + final bool active; + final VoidCallback onTap; + const _PayChip({ + required this.label, + required this.icon, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = active ? _kPrimary : context.subtle; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: active + ? _kPrimary.withOpacity(0.1) + : context.isDark + ? Colors.white.withOpacity(0.05) + : Colors.black.withOpacity(0.03), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: active ? _kPrimary.withOpacity(0.5) : color.withOpacity(0.2)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + if (active) + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon(Icons.check, size: 13, color: _kPrimary), + ), + Icon(icon, size: 15, color: color), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color)), + ]), + ), + ); } }