Compare commits

...
10 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 2d5ff1581b Fix dark/light theme support in redesigned views
Replace hardcoded colors with Theme.of(context) values via BuildContext
extension (_Th): bg, card, onSurface, muted, subtle, divider, shadow,
inputFill, chipBg. Affects: professional_profile_view, professional_
calendar_view, services_requests_view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:45:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 142e3ffafd 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 <noreply@anthropic.com>
2026-06-29 11:41:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 024d2ec5f5 Update mode toggle button: swap_horiz icon + shorter label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:37:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ef8ccb9aee Redesign professional profile UI + sidebar mode indicator
- professional_profile_view: gradient header card with avatar overlay,
  sectioned cards (service modality, address, rate, payment chips, schedule),
  animated payment method chips, full-width save button with loading state
- sidebar: teal-dark background in pro mode vs navy in user mode,
  animated mode indicator pill, muted toggle button in pro mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:34:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9f2612ec70 Add diploma/certificate upload to professional request form (required field)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:50:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3e59a1cb4f Fix retry: show error on failure, reload professional data after reset
- Stop swallowing DELETE errors silently; show snackbar instead
- After successful reset, reload professional from server and notify providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:00:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dba8eaa709 Fix retry button not showing: add missing ApiService import and show button immediately
- Add missing api_service.dart import (without it the file fails to compile)
- Initialize _loadingSettings = false so button renders before async settings load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:49:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 391c32f765 Rejection retry flow with configurable wait days
- Usuario model: proRejectedAt parsed from professionals.updated_at
- _StatusCard: shows countdown or retry button based on rejection_wait_days setting
- Retry clears the rejected record via DELETE /professionals/me

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 18:03:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9634aa13ff Add cedula number field to professional request form
Shows document type locked to 'Cédula de ciudadanía' and adds required
number input field above the photo upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 17:49:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 28511491af Fix pro state not updating after submit: use context.watch for AuthProvider
context.read doesn't subscribe to changes, so the form stayed visible
after submitForReview even though AuthProvider was updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 17:27:12 -05:00
6 changed files with 1355 additions and 933 deletions
+15
View File
@@ -11,6 +11,7 @@ class Usuario {
final String? birthday;
final String? gender;
final ProState proState;
final DateTime? proRejectedAt;
final String? token;
final bool isPhoneVerified;
@@ -26,6 +27,7 @@ class Usuario {
required this.gender,
required this.proState,
required this.token,
this.proRejectedAt,
this.isPhoneVerified = false,
});
@@ -57,11 +59,24 @@ class Usuario {
birthday: doc['birthday'],
gender: doc['gender'],
proState: intToEnum((doc['professional_state'] as int?) ?? 0),
proRejectedAt: _parseRejectedAt(doc),
token: doc['token'],
isPhoneVerified: doc['is_phone_verified'] as bool? ?? false,
);
}
static DateTime? _parseRejectedAt(Map<String, dynamic> doc) {
// /auth/me returns professionals nested; if pro_state==3 use professionals.updated_at
final state = (doc['professional_state'] as int?) ?? 0;
if (state != 3) return null;
final pros = doc['professionals'];
if (pros is Map) {
final raw = pros['updated_at'];
if (raw is String) return DateTime.tryParse(raw);
}
return null;
}
@override
String toString() {
return 'User(id: $id, email: $email, phone: $phone, name: $name, nickname: $nickname, city: $city, picture: $picture, birthday: $birthday, gender: $gender, proState: $proState, token: $token)';
+77 -18
View File
@@ -29,13 +29,62 @@ class Sidebar extends StatelessWidget {
return Container(
width: 220,
height: double.infinity,
decoration: buildBoxDecoration(),
decoration: buildBoxDecoration(professionalProvider.isProModeActive),
child: ListView(
physics: const ClampingScrollPhysics(),
children: [
const Logo(),
const SizedBox(height: 8),
// Mode indicator pill
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: professionalProvider.isProModeActive
? const Color(0xFF16A34A).withOpacity(0.18)
: const Color(0xFF42A4EF).withOpacity(0.14),
border: Border.all(
color: professionalProvider.isProModeActive
? const Color(0xFF22C55E).withOpacity(0.5)
: const Color(0xFF42A4EF).withOpacity(0.4),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
professionalProvider.isProModeActive
? Icons.work_outline
: Icons.person_outline,
size: 13,
color: professionalProvider.isProModeActive
? const Color(0xFF4ADE80)
: const Color(0xFF7DD3FC),
),
const SizedBox(width: 6),
Text(
professionalProvider.isProModeActive
? 'Modo Profesional'
: 'Modo Usuario',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: professionalProvider.isProModeActive
? const Color(0xFF4ADE80)
: const Color(0xFF7DD3FC),
),
),
],
),
),
),
const SizedBox(height: 4),
if (professionalProvider.isProModeActive) ...[
const TextSeparator(text: 'Profesional'),
MenuItem(
@@ -147,29 +196,40 @@ class Sidebar extends StatelessWidget {
navigateTo(Flurorouter.requestProfessionalRoute);
}
},
child: Container(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
gradient: LinearGradient(
colors: professionalProvider.isProModeActive
? [const Color(0xFF374151), const Color(0xFF1F2937)]
: [const Color(0xFF42A4EF), const Color(0xFF1565C0)],
),
borderRadius: BorderRadius.circular(10),
border: professionalProvider.isProModeActive
? Border.all(
color: Colors.white.withOpacity(0.1), width: 1)
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
professionalProvider.isProModeActive
? Icons.person_outlined
: Icons.work_outline,
color: Colors.white,
size: 16,
Icons.swap_horiz,
color: professionalProvider.isProModeActive
? Colors.white70
: Colors.white,
size: 18,
),
const SizedBox(width: 8),
Text(
'Modo ${professionalProvider.isProModeActive ? 'Usuario' : 'Profesional'}',
style: const TextStyle(
color: Colors.white,
professionalProvider.isProModeActive
? 'Cambiar a Usuario'
: 'Cambiar a Profesional',
style: TextStyle(
color: professionalProvider.isProModeActive
? Colors.white70
: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
@@ -193,16 +253,15 @@ class Sidebar extends StatelessWidget {
);
}
BoxDecoration buildBoxDecoration() => const BoxDecoration(
BoxDecoration buildBoxDecoration(bool isProMode) => BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF0D1B3E),
Color(0xFF0A1628),
],
colors: isProMode
? const [Color(0xFF0A1F18), Color(0xFF061510)]
: const [Color(0xFF0D1B3E), Color(0xFF0A1628)],
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: Colors.black38,
blurRadius: 12,
+290 -299
View File
@@ -2,17 +2,31 @@ 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 _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 ProfessionalCalendarView extends StatefulWidget {
const ProfessionalCalendarView({super.key});
@@ -22,335 +36,312 @@ class ProfessionalCalendarView extends StatefulWidget {
}
class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
DateTime today = DateTime.now();
late int numDay;
DateTime _selected = DateTime.now();
List<Service>? _services;
Usuario? user;
bool _loading = true;
@override
void initState() {
super.initState();
numDay = today.weekday;
_fetchProfessionalAndServices();
_load();
}
void _fetchProfessionalAndServices() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final professionalFormProvider = Provider.of<ProfessionalFormProvider>(context, listen: false);
final servicesProvider = Provider.of<ServicesProvider>(context, listen: false);
final proProvider = Provider.of<ProfessionalProvider>(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<void> _load() async {
setState(() => _loading = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
final sp = Provider.of<ServicesProvider>(context, listen: false);
final pro = await Provider.of<ProfessionalProvider>(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<ProfessionalFormProvider>(
builder: (context, professionalFormProvider, child) {
if (professionalFormProvider.profesional == null) {
return const Center(
child: CircularProgressIndicator(),
return Container(
color: context.bg,
child: Consumer<ProfessionalFormProvider>(
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(context),
_dayHeader(context, schedule, slots.length, occupied),
if (slots.isEmpty)
_emptyState(context)
else
..._slotCards(context, slots),
],
);
}
},
),
);
}
final profesional = professionalFormProvider.profesional!;
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(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 20, 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_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),
defaultTextStyle: TextStyle(color: context.onSurface),
outsideDaysVisible: false,
),
headerStyle: HeaderStyle(
formatButtonVisible: false,
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)),
),
),
],
);
},
),
),
),
);
}
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;
}
Widget _dayHeader(BuildContext context, 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: 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(_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: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: context.onSurface)),
Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)),
])),
if (hasSchedule && total > 0) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8),
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
],
]),
),
),
);
}
List<Widget> _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"),
)
];
}
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots) {
return slots.map((time) {
final occ = _isOccupied(time, _services, _selected);
final matchService = occ ? _serviceFor(time, _services, _selected) : null;
final color = occ ? _kOccupied : _kAvailable;
if (schedule.continuousDay) {
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: 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: 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)),
if (matchService != null && matchService.description.isNotEmpty)
Text(matchService.description,
style: TextStyle(fontSize: 12, color: context.muted),
maxLines: 1, overflow: TextOverflow.ellipsis)
else if (!occ)
Text('Horario libre para nuevas citas',
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();
}
return rangesItemList(ranges, _services, today, context);
}
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
Widget _emptyState(BuildContext context) {
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: 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? _scheduleFor(int weekday, Profesional pro) => 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<TimeOfDay> _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<Service>? 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<Service>? 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<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? 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<Service>? 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,
),
),
),
);
}
}).toList();
String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
class _StatPill extends StatelessWidget {
final String label;
final String sublabel;
final Color color;
const _StatPill({required this.label, required this.sublabel, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(label,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: color, height: 1)),
Text(sublabel,
style: TextStyle(fontSize: 9, color: color.withOpacity(0.8), fontWeight: FontWeight.w600)),
]),
);
}
}
File diff suppressed because it is too large Load Diff
+195 -70
View File
@@ -10,6 +10,7 @@ import 'package:prosapp_web_app/providers/professional_provider.dart';
import 'package:prosapp_web_app/providers/professions_provider.dart';
import 'package:prosapp_web_app/providers/profile_form_provider.dart';
import 'package:prosapp_web_app/providers/theme_provider.dart';
import 'package:prosapp_web_app/services/api_service.dart';
import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:provider/provider.dart';
@@ -65,16 +66,20 @@ class _FormBody extends StatefulWidget {
class _FormBodyState extends State<_FormBody> {
final _rethusCtrl = TextEditingController();
final _specCtrl = TextEditingController();
final _cedulaNumCtrl = TextEditingController();
String? _selectedProfession;
List<String> _specs = [];
bool _loading = false;
bool _cedulaError = false;
bool _cedulaNumError = false;
bool _certError = false;
bool _profError = false;
@override
void dispose() {
_rethusCtrl.dispose();
_specCtrl.dispose();
_cedulaNumCtrl.dispose();
super.dispose();
}
@@ -88,25 +93,30 @@ class _FormBodyState extends State<_FormBody> {
}
}
bool _validate(String cedulaUrl) {
bool _validate(String cedulaUrl, String certUrl) {
final cErr = cedulaUrl.isEmpty;
final pErr =
_selectedProfession == null || _selectedProfession!.isEmpty;
final nErr = _cedulaNumCtrl.text.trim().isEmpty;
final dErr = certUrl.isEmpty;
final pErr = _selectedProfession == null || _selectedProfession!.isEmpty;
setState(() {
_cedulaError = cErr;
_cedulaNumError = nErr;
_certError = dErr;
_profError = pErr;
});
return !cErr && !pErr;
return !cErr && !nErr && !dErr && !pErr;
}
Future<void> _submit() async {
final fp = context.read<ProfessionalFormProvider>();
final cedulaUrl = fp.profesional?.identificationPicture ?? '';
if (!_validate(cedulaUrl)) return;
final certUrl = fp.profesional?.certificatePicture ?? '';
if (!_validate(cedulaUrl, certUrl)) return;
setState(() => _loading = true);
try {
fp.copyProfesionalWith(
identification: _cedulaNumCtrl.text.trim(),
profession: _selectedProfession,
rethusCode: _rethusCtrl.text.trim(),
specializations: _specs,
@@ -151,7 +161,7 @@ class _FormBodyState extends State<_FormBody> {
Widget build(BuildContext context) {
final isDark = context.watch<ThemeProvider>().isDark;
final fp = context.watch<ProfessionalFormProvider>();
final user = context.read<AuthProvider>().user!;
final user = context.watch<AuthProvider>().user!;
final professions = context.watch<ProfessionsProvider>().professions;
final proStateInt = enumToInt(user.proState);
@@ -193,6 +203,8 @@ class _FormBodyState extends State<_FormBody> {
final cedulaUploaded =
(fp.profesional?.identificationPicture ?? '').isNotEmpty;
final certUploaded =
(fp.profesional?.certificatePicture ?? '').isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -228,30 +240,58 @@ class _FormBodyState extends State<_FormBody> {
const SizedBox(height: 16),
// ── 1. Foto / PDF de cédula (obligatorio) ──────────────────────────
// ── 1. Cédula (número + foto) ───────────────────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Foto / PDF de cédula', Icons.badge_outlined,
required: true),
const SizedBox(height: 12),
_Label('Cédula de ciudadanía', Icons.badge_outlined, required: true),
const SizedBox(height: 4),
// Tipo fijo
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF334155) : const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border),
),
child: Row(
children: [
Icon(Icons.lock_outline, size: 14, color: textSec),
const SizedBox(width: 6),
Text('Tipo: Cédula de ciudadanía', style: TextStyle(fontSize: 13, color: textSec)),
],
),
),
const SizedBox(height: 10),
// Número
TextField(
controller: _cedulaNumCtrl,
keyboardType: TextInputType.number,
style: TextStyle(color: textPrimary, fontSize: 14),
decoration: InputDecoration(
hintText: 'Número de cédula',
hintStyle: TextStyle(color: textSec, fontSize: 13),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
errorText: _cedulaNumError ? 'Ingresa tu número de cédula' : null,
),
onChanged: (_) { if (_cedulaNumError) setState(() => _cedulaNumError = false); },
),
const SizedBox(height: 10),
// Foto
_UploadTile(
label: cedulaUploaded
? 'Cédula subida ✓'
: 'Subir foto o PDF de la cédula',
label: cedulaUploaded ? 'Foto de cédula subida ✓' : 'Subir foto o PDF de la cédula',
uploaded: cedulaUploaded,
onTap: () => _pickAndUpload(
(b) => fp.uploadPdfIdentification(b, user.id)),
onTap: () => _pickAndUpload((b) => fp.uploadPdfIdentification(b, user.id)),
),
if (_cedulaError)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Debes subir la foto de tu cédula',
style: TextStyle(
color: Colors.redAccent, fontSize: 12)),
style: TextStyle(color: Colors.redAccent, fontSize: 12)),
),
],
),
@@ -259,7 +299,38 @@ class _FormBodyState extends State<_FormBody> {
const SizedBox(height: 10),
// ── 2. Profesión (obligatorio) ──────────────────────────────────────
// ── 2. Diploma / Certificado (obligatorio) ─────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Diploma o certificado profesional', Icons.school_outlined, required: true),
const SizedBox(height: 4),
Text(
'Sube el diploma, acta de grado o tarjeta profesional',
style: TextStyle(fontSize: 11, color: textSec),
),
const SizedBox(height: 10),
_UploadTile(
label: certUploaded ? 'Documento subido ✓' : 'Subir diploma / certificado (PDF o imagen)',
uploaded: certUploaded,
onTap: () => _pickAndUpload((b) => fp.uploadPdfCertificate(b, user.id)),
),
if (_certError)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Debes subir el diploma o certificado',
style: TextStyle(color: Colors.redAccent, fontSize: 12)),
),
],
),
),
const SizedBox(height: 10),
// ── 4. Profesión (obligatorio) ──────────────────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
@@ -578,7 +649,7 @@ class _UploadTile extends StatelessWidget {
}
}
class _StatusCard extends StatelessWidget {
class _StatusCard extends StatefulWidget {
final Color cardBg;
final Color border;
final Color textPrimary;
@@ -593,26 +664,73 @@ class _StatusCard extends StatelessWidget {
required this.isPending,
});
@override
State<_StatusCard> createState() => _StatusCardState();
}
class _StatusCardState extends State<_StatusCard> {
int _waitDays = 7;
bool _loadingSettings = false;
@override
void initState() {
super.initState();
if (!widget.isPending) _loadWaitDays();
}
Future<void> _loadWaitDays() async {
try {
final data = await ApiService.instance.get('/settings') as Map<String, dynamic>;
final v = data['rejection_wait_days'];
if (v != null) setState(() => _waitDays = (v as num).toInt());
} catch (_) {}
if (mounted) setState(() => _loadingSettings = false);
}
Future<void> _retry() async {
try {
await ApiService.instance.delete('/professionals/me');
} catch (e) {
if (mounted) NotificationsService.showSnackbar('Error al reiniciar solicitud: $e');
return;
}
if (!mounted) return;
await context.read<AuthProvider>().isAuthenticated();
if (!mounted) return;
// Reload professional data so the form shows fresh state
final auth = context.read<AuthProvider>();
context.read<ProfessionalProvider>()
.getProfessional(auth.user!.id)
.then((pro) => context.read<ProfessionalFormProvider>().setProfesional(pro))
.catchError((_) => context.read<ProfessionalFormProvider>().setProfesional(Profesional.empty()));
}
@override
Widget build(BuildContext context) {
final accent = isPending
final accent = widget.isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
final user = context.watch<AuthProvider>().user;
final rejectedAt = user?.proRejectedAt;
final daysSince = rejectedAt != null
? DateTime.now().difference(rejectedAt).inDays
: _waitDays;
final daysLeft = (_waitDays - daysSince).clamp(0, _waitDays);
final canRetry = !widget.isPending && daysLeft == 0;
return Container(
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: cardBg,
color: widget.cardBg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: border),
border: Border.all(color: widget.border),
),
child: Column(
children: [
if (isPending)
const Image(
image: AssetImage('assets/checklist.gif'),
width: 160)
if (widget.isPending)
const Image(image: AssetImage('assets/checklist.gif'), width: 160)
else
Container(
padding: const EdgeInsets.all(20),
@@ -620,63 +738,70 @@ class _StatusCard extends StatelessWidget {
color: accent.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.sentiment_dissatisfied_outlined,
size: 52,
color: accent),
child: Icon(Icons.sentiment_dissatisfied_outlined, size: 52, color: accent),
),
const SizedBox(height: 18),
Text(
isPending
? 'Solicitud en revisión'
: 'Solicitud no aprobada',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
color: textPrimary),
widget.isPending ? 'Solicitud en revisión' : 'Solicitud no aprobada',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold, color: widget.textPrimary),
),
const SizedBox(height: 10),
Text(
isPending
widget.isPending
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
: 'Tu solicitud no fue aprobada. Revisa tus documentos y vuelve a intentarlo.',
: canRetry
? 'Puedes volver a enviar tu solicitud con los documentos corregidos.'
: 'Tu solicitud no fue aprobada. Podrás reintentar en $daysLeft día${daysLeft == 1 ? '' : 's'}.',
textAlign: TextAlign.center,
style:
TextStyle(color: textSec, fontSize: 13, height: 1.5),
style: TextStyle(color: widget.textSec, fontSize: 13, height: 1.5),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border:
Border.all(color: accent.withOpacity(0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.cancel_outlined,
color: accent,
size: 16,
if (!widget.isPending && !_loadingSettings) ...[
if (canRetry)
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Volver a solicitar'),
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF42A4EF)),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accent.withOpacity(0.4)),
),
const SizedBox(width: 6),
Text(
isPending ? 'Revisión en proceso' : 'No aprobado',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w600,
fontSize: 13),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, color: accent, size: 16),
const SizedBox(width: 6),
Text(
'Disponible en $daysLeft día${daysLeft == 1 ? '' : 's'}',
style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13),
),
],
),
],
),
] else if (widget.isPending)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accent.withOpacity(0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_empty_rounded, color: accent, size: 16),
const SizedBox(width: 6),
Text('Revisión en proceso',
style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13)),
],
),
),
),
],
),
);
+247 -112
View File
@@ -3,138 +3,273 @@ 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);
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.05);
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
}
class ServicesRequestsView extends StatelessWidget {
const ServicesRequestsView({super.key});
@override
Widget build(BuildContext context) {
final servicesProvider =
Provider.of<ServicesProvider>(context, listen: false);
servicesProvider.getServicesRequestsForProfessional(
final sp = Provider.of<ServicesProvider>(context, listen: false);
sp.getServicesRequestsForProfessional(
Provider.of<AuthProvider>(context, listen: false).user!.id);
return Container(
color: context.bg,
child: Consumer<ServicesProvider>(
builder: (context, sp, _) {
if (sp.isLoading) {
return const Center(child: CircularProgressIndicator(color: _kPrimary));
}
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 32),
children: [
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),
Text('Solicitudes',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w800, color: context.onSurface)),
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(context)
else
...sp.services.map((data) => _RequestCard(data: data)),
],
);
},
),
);
}
Widget _emptyState(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Consumer<ServicesProvider>(
builder: (context, servicesProvider, child) {
if (servicesProvider.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
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),
),
],
),
),
],
),
),
),
),
);
},
);
},
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: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadow, 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.inbox_outlined, size: 32, color: context.subtle),
),
const SizedBox(height: 16),
Text('Sin solicitudes pendientes',
style: TextStyle(fontSize: 15, 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)),
]),
),
),
);
}
}
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: 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: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => NavigationService.replaceTo(
'/dashboard/professional/service/${service.id}'),
child: IntrinsicHeight(
child: Row(children: [
Container(width: 4, color: statusInfo.color),
Padding(
padding: const EdgeInsets.all(14),
child: 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),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
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: 6),
Row(children: [
Icon(Icons.calendar_today_outlined, size: 12, color: context.subtle),
const SizedBox(width: 4),
Text('$dateStr · $timeStr',
style: TextStyle(
fontSize: 12, color: context.muted, fontWeight: FontWeight.w500)),
]),
],
),
),
),
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: context.subtle),
],
),
),
]),
),
),
),
),
),
),
);
}
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) => 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),
};