Files
prosappweb/lib/ui/views/request_professional_view.dart
T
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

718 lines
25 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/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 _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) {
final cErr = cedulaUrl.isEmpty;
final nErr = _cedulaNumCtrl.text.trim().isEmpty;
final pErr = _selectedProfession == null || _selectedProfession!.isEmpty;
setState(() {
_cedulaError = cErr;
_cedulaNumError = nErr;
_profError = pErr;
});
return !cErr && !nErr && !pErr;
}
Future<void> _submit() async {
final fp = context.read<ProfessionalFormProvider>();
final cedulaUrl = fp.profesional?.identificationPicture ?? '';
if (!_validate(cedulaUrl)) 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;
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. 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 StatelessWidget {
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
Widget build(BuildContext context) {
final accent = isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
return Container(
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: border),
),
child: Column(
children: [
if (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(
isPending
? Icons.hourglass_empty_rounded
: 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),
),
const SizedBox(height: 10),
Text(
isPending
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
: 'Tu solicitud no fue aprobada. Revisa tus documentos y vuelve a intentarlo.',
textAlign: TextAlign.center,
style:
TextStyle(color: 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,
),
const SizedBox(width: 6),
Text(
isPending ? 'Revisión en proceso' : 'No aprobado',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w600,
fontSize: 13),
),
],
),
),
],
),
);
}
}