Files
prosappweb/lib/ui/views/request_professional_view.dart

810 lines
29 KiB
Dart

import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:prosapp_web_app/models/pro_state.dart';
import 'package:prosapp_web_app/models/profesional.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/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';
class RequestProfessionalView extends StatefulWidget {
const RequestProfessionalView({super.key});
@override
State<RequestProfessionalView> createState() =>
_RequestProfessionalViewState();
}
class _RequestProfessionalViewState extends State<RequestProfessionalView> {
@override
void initState() {
super.initState();
final auth = context.read<AuthProvider>();
context.read<ProfileFormProvider>().user = auth.user;
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) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: const _FormBody(),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _FormBody extends StatefulWidget {
const _FormBody();
@override
State<_FormBody> createState() => _FormBodyState();
}
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();
}
void _addSpec() {
final v = _specCtrl.text.trim();
if (v.isNotEmpty && !_specs.contains(v)) {
setState(() {
_specs.add(v);
_specCtrl.clear();
});
}
}
bool _validate(String cedulaUrl, String certUrl) {
final cErr = cedulaUrl.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 && !nErr && !dErr && !pErr;
}
Future<void> _submit() async {
final fp = context.read<ProfessionalFormProvider>();
final cedulaUrl = fp.profesional?.identificationPicture ?? '';
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,
);
await fp.submitForReview();
if (!mounted) return;
final pfp = context.read<ProfileFormProvider>();
pfp.copyUserWith(proState: ProState.pending);
await pfp.updateUserInfoNoValid();
await context.read<AuthProvider>().isAuthenticated();
} catch (e) {
if (mounted) {
NotificationsService.showSnackbar('Error al enviar: ${e.toString()}');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _pickAndUpload(
Future<dynamic> Function(Uint8List) upload,
) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result == null || result.files.first.bytes == null) return;
if (!mounted) return;
NotificationsService.showBusyIndicator(context);
await upload(result.files.first.bytes!);
if (mounted) {
Navigator.pop(context);
setState(() => _cedulaError = false);
}
}
@override
Widget build(BuildContext context) {
final isDark = context.watch<ThemeProvider>().isDark;
final fp = context.watch<ProfessionalFormProvider>();
final user = context.watch<AuthProvider>().user!;
final professions = context.watch<ProfessionsProvider>().professions;
final proStateInt = enumToInt(user.proState);
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
final border =
isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
final textPrimary = isDark ? Colors.white : const Color(0xFF111827);
final textSec =
isDark ? const Color(0xFF94A3B8) : const Color(0xFF6B7280);
if (fp.profesional == null) {
return const Padding(
padding: EdgeInsets.only(top: 60),
child: Center(child: CircularProgressIndicator()),
);
}
// En revisión
if (proStateInt == 1) {
return _StatusCard(
cardBg: cardBg,
border: border,
textPrimary: textPrimary,
textSec: textSec,
isPending: true,
);
}
// Rechazado
if (proStateInt == 3) {
return _StatusCard(
cardBg: cardBg,
border: border,
textPrimary: textPrimary,
textSec: textSec,
isPending: false,
);
}
final cedulaUploaded =
(fp.profesional?.identificationPicture ?? '').isNotEmpty;
final certUploaded =
(fp.profesional?.certificatePicture ?? '').isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Header ──────────────────────────────────────────────────────────
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
children: [
Icon(Icons.verified_user_outlined,
color: Colors.white, size: 34),
SizedBox(height: 8),
Text('Solicitud de Profesional',
style: TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('Completa tu información para ofrecer servicios',
style: TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center),
],
),
),
const SizedBox(height: 16),
// ── 1. Cédula (número + foto) ───────────────────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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 ? 'Foto de cédula subida ✓' : 'Subir foto o PDF de la cédula',
uploaded: cedulaUploaded,
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)),
),
],
),
),
const SizedBox(height: 10),
// ── 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,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Profesión', Icons.work_outline, required: true),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _selectedProfession,
dropdownColor: cardBg,
style: TextStyle(color: textPrimary, fontSize: 14),
hint: Text('Selecciona tu profesión',
style: TextStyle(color: textSec, fontSize: 13)),
decoration: _dec(isDark, border),
items: professions
.map((p) => DropdownMenuItem(
value: p.name,
child: Text(p.name,
style: TextStyle(color: textPrimary)),
))
.toList(),
onChanged: (v) => setState(() {
_selectedProfession = v;
_profError = false;
}),
),
if (_profError)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Selecciona una profesión',
style: TextStyle(
color: Colors.redAccent, fontSize: 12)),
),
],
),
),
const SizedBox(height: 10),
// ── 3. RETHUS (opcional) ────────────────────────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Código RETHUS',
Icons.health_and_safety_outlined,
required: false),
const SizedBox(height: 4),
Text(
'Registro del talento humano en salud de Colombia',
style:
TextStyle(fontSize: 11, color: textSec)),
const SizedBox(height: 12),
TextField(
controller: _rethusCtrl,
style: TextStyle(color: textPrimary, fontSize: 14),
decoration: _dec(isDark, border)
.copyWith(hintText: 'Ej: 12345678'),
),
],
),
),
const SizedBox(height: 10),
// ── 4. Especializaciones (opcional) ────────────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Especializaciones',
Icons.assignment_outlined,
required: false),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _specCtrl,
style:
TextStyle(color: textPrimary, fontSize: 14),
onSubmitted: (_) => _addSpec(),
decoration: _dec(isDark, border).copyWith(
hintText: 'Ej: Fisioterapia deportiva'),
),
),
const SizedBox(width: 8),
Material(
color: const Color(0xFF42A4EF),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: _addSpec,
borderRadius: BorderRadius.circular(10),
child: const Padding(
padding: EdgeInsets.all(13),
child: Icon(Icons.add,
color: Colors.white, size: 20),
),
),
),
],
),
if (_specs.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: _specs
.map((s) => Chip(
label: Text(s,
style: const TextStyle(
color: Color(0xFF42A4EF),
fontSize: 12)),
backgroundColor: const Color(0xFF42A4EF)
.withOpacity(0.1),
side: const BorderSide(
color: Color(0xFF42A4EF), width: 0.6),
deleteIconColor:
const Color(0xFF42A4EF),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(8)),
onDeleted: () =>
setState(() => _specs.remove(s)),
))
.toList(),
),
],
],
),
),
const SizedBox(height: 20),
// ── Botón enviar ────────────────────────────────────────────────────
SizedBox(
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
disabledBackgroundColor:
const Color(0xFF42A4EF).withOpacity(0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Enviar a revisión',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 24),
],
);
}
InputDecoration _dec(bool isDark, Color border) {
final fill =
isDark ? const Color(0xFF0F172A) : const Color(0xFFF9FAFB);
final hint =
isDark ? const Color(0xFF64748B) : const Color(0xFF9CA3AF);
return InputDecoration(
hintStyle: TextStyle(color: hint, fontSize: 13),
filled: true,
fillColor: fill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: Color(0xFF42A4EF), width: 2)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Widgets de apoyo
// ─────────────────────────────────────────────────────────────────────────────
class _SectionCard extends StatelessWidget {
final Color cardBg;
final Color border;
final Widget child;
const _SectionCard(
{required this.cardBg, required this.border, required this.child});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: border),
),
child: child,
);
}
class _Label extends StatelessWidget {
final String text;
final IconData icon;
final bool required;
const _Label(this.text, this.icon, {required this.required});
@override
Widget build(BuildContext context) {
final isDark = context.watch<ThemeProvider>().isDark;
final textPrimary = isDark ? Colors.white : const Color(0xFF111827);
return Row(
children: [
Icon(icon, size: 16, color: const Color(0xFF42A4EF)),
const SizedBox(width: 6),
Text(text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: textPrimary)),
if (required) ...[
const SizedBox(width: 3),
const Text('*',
style: TextStyle(
color: Colors.redAccent,
fontSize: 15,
fontWeight: FontWeight.bold)),
] else
Padding(
padding: const EdgeInsets.only(left: 6),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFF64748B).withOpacity(0.12),
borderRadius: BorderRadius.circular(4),
),
child: const Text('opcional',
style: TextStyle(
fontSize: 10, color: Color(0xFF64748B))),
),
),
],
);
}
}
class _UploadTile extends StatelessWidget {
final String label;
final bool uploaded;
final VoidCallback onTap;
const _UploadTile(
{required this.label,
required this.uploaded,
required this.onTap});
@override
Widget build(BuildContext context) {
final color =
uploaded ? const Color(0xFF10B981) : const Color(0xFF42A4EF);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: color.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.35)),
),
child: Row(
children: [
Icon(
uploaded
? Icons.check_circle_outline
: Icons.upload_file_outlined,
color: color,
size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(label,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w500)),
),
Icon(
uploaded
? Icons.edit_outlined
: Icons.chevron_right,
color: color.withOpacity(0.6),
size: 18),
],
),
),
);
}
}
class _StatusCard extends StatefulWidget {
final Color cardBg;
final Color border;
final Color textPrimary;
final Color textSec;
final bool isPending;
const _StatusCard({
required this.cardBg,
required this.border,
required this.textPrimary,
required this.textSec,
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 = 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: widget.cardBg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: widget.border),
),
child: Column(
children: [
if (widget.isPending)
const Image(image: AssetImage('assets/checklist.gif'), width: 160)
else
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: accent.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.sentiment_dissatisfied_outlined, size: 52, color: accent),
),
const SizedBox(height: 18),
Text(
widget.isPending ? 'Solicitud en revisión' : 'Solicitud no aprobada',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold, color: widget.textPrimary),
),
const SizedBox(height: 10),
Text(
widget.isPending
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
: 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: widget.textSec, fontSize: 13, height: 1.5),
),
const SizedBox(height: 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)),
),
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)),
],
),
),
],
),
);
}
}