first commit
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../store/patient_store.dart';
|
||||
|
||||
class PatientDemographicsForm extends StatelessWidget {
|
||||
/// Altura estricta y compartida para todos los elementos de la ficha demográfica:
|
||||
/// los 8 campos de texto, las 3 secciones de toggle (sexo, seguro, estado civil)
|
||||
/// y el botón Guardar Paciente siempre tendrán exactamente la misma altura.
|
||||
static const double unifiedRowHeight = 46.0;
|
||||
|
||||
final PatientStore store;
|
||||
final GlobalKey<FormBuilderState> formKey;
|
||||
final VoidCallback onSave;
|
||||
|
||||
const PatientDemographicsForm({
|
||||
super.key,
|
||||
required this.store,
|
||||
required this.formKey,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
InputDecoration _cleanDecoration({
|
||||
required String label,
|
||||
IconData? icon,
|
||||
bool isRequired = false,
|
||||
String? hintText,
|
||||
bool readOnly = false,
|
||||
}) {
|
||||
return InputDecoration(
|
||||
labelText: isRequired ? '$label *' : label,
|
||||
hintText: hintText,
|
||||
isDense: true,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
filled: true,
|
||||
fillColor: readOnly ? const Color(0xFFF1F5F9) : Colors.white,
|
||||
prefixIcon: icon != null
|
||||
? Icon(icon, size: 16, color: isRequired ? AppColors.primary : AppColors.textMuted)
|
||||
: null,
|
||||
prefixIconConstraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isRequired ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
hintStyle: const TextStyle(fontSize: 11.5, color: Color(0xFF94A3B8)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactSelector({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required IconData titleIcon,
|
||||
required Widget child,
|
||||
bool isRequired = false,
|
||||
}) {
|
||||
return Container(
|
||||
height: unifiedRowHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(titleIcon, size: 14, color: isRequired ? AppColors.primary : AppColors.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isRequired ? '$title *' : title,
|
||||
style: TextStyle(
|
||||
fontSize: 11.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isRequired ? AppColors.primary : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = store;
|
||||
|
||||
final cedulaFormatter = MaskTextInputFormatter(
|
||||
mask: '###-#######-#',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
);
|
||||
|
||||
final telefonoFormatter = MaskTextInputFormatter(
|
||||
mask: '###-###-####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFE2E8F0), width: 1.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Sub-bloque 1 (Columna 1): Fecha de reg, Nombre, Dirección, Ocupación
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'fec_reg',
|
||||
initialValue: s.fecReg,
|
||||
readOnly: true,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Fecha de Registro',
|
||||
icon: Icons.calendar_today_rounded,
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'nombre',
|
||||
initialValue: s.nombre,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Nombre',
|
||||
icon: Icons.person_outline_rounded,
|
||||
isRequired: true,
|
||||
hintText: 'Ej. Juan Alberto',
|
||||
),
|
||||
validator: FormBuilderValidators.compose([
|
||||
FormBuilderValidators.required(errorText: 'Requerido'),
|
||||
FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'),
|
||||
]),
|
||||
onChanged: (v) => s.nombre = v ?? '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'direccion',
|
||||
initialValue: s.direccion,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Dirección',
|
||||
icon: Icons.location_on_outlined,
|
||||
hintText: 'Calle, número o sector',
|
||||
),
|
||||
onChanged: (v) => s.direccion = v ?? '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'ocupacion',
|
||||
initialValue: s.ocupacion,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Ocupación',
|
||||
icon: Icons.work_outline_rounded,
|
||||
hintText: 'Profesión u oficio',
|
||||
),
|
||||
onChanged: (v) => s.ocupacion = v ?? '',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Sub-bloque 2 (Columna 2): Cédula o Pasaporte, Apellido, Teléfono, Edad
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'cedula',
|
||||
initialValue: s.cedula,
|
||||
inputFormatters: [cedulaFormatter],
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Cédula o Pasaporte',
|
||||
icon: Icons.badge_outlined,
|
||||
hintText: '001-0000000-0',
|
||||
),
|
||||
onChanged: (v) => s.cedula = v ?? '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'apellido',
|
||||
initialValue: s.apellido,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Apellido',
|
||||
icon: Icons.badge_rounded,
|
||||
isRequired: true,
|
||||
hintText: 'Ej. Pérez Gómez',
|
||||
),
|
||||
validator: FormBuilderValidators.compose([
|
||||
FormBuilderValidators.required(errorText: 'Requerido'),
|
||||
FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'),
|
||||
]),
|
||||
onChanged: (v) => s.apellido = v ?? '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'telefono',
|
||||
initialValue: s.telefono,
|
||||
inputFormatters: [telefonoFormatter],
|
||||
keyboardType: TextInputType.phone,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Teléfono',
|
||||
icon: Icons.phone_outlined,
|
||||
hintText: '809-555-0101',
|
||||
),
|
||||
onChanged: (v) => s.telefono = v ?? '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
child: FormBuilderTextField(
|
||||
name: 'edad',
|
||||
initialValue: s.edad,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: _cleanDecoration(
|
||||
label: 'Edad',
|
||||
icon: Icons.cake_outlined,
|
||||
hintText: 'Años',
|
||||
),
|
||||
onChanged: (v) => s.edad = v ?? '',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Sub-bloque 3 (Columna 3): Sexo, Asegurado, Estado Civil, Botón Guardar
|
||||
Expanded(
|
||||
flex: 10,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCompactSelector(
|
||||
context: context,
|
||||
title: 'Sexo',
|
||||
titleIcon: Icons.wc_rounded,
|
||||
isRequired: true,
|
||||
child: FormBuilderField<String>(
|
||||
name: 'sexo',
|
||||
initialValue: s.sexo,
|
||||
validator: FormBuilderValidators.required(errorText: 'Requerido'),
|
||||
builder: (FormFieldState<String> field) {
|
||||
final currentVal = field.value ?? s.sexo;
|
||||
final initialIndex = currentVal.toLowerCase().contains('fem') ? 1 : 0;
|
||||
return ToggleSwitch(
|
||||
key: ValueKey('sexo_$currentVal'),
|
||||
customWidths: const [108.0, 108.0],
|
||||
minHeight: 28.0,
|
||||
cornerRadius: 6.0,
|
||||
radiusStyle: true,
|
||||
fontSize: 10.5,
|
||||
iconSize: 13.0,
|
||||
centerText: true,
|
||||
initialLabelIndex: initialIndex,
|
||||
totalSwitches: 2,
|
||||
labels: const ['Masculino', 'Femenino'],
|
||||
icons: const [Icons.male_rounded, Icons.female_rounded],
|
||||
activeBgColor: const [AppColors.primary],
|
||||
activeFgColor: Colors.white,
|
||||
inactiveBgColor: const Color(0xFFE2E8F0),
|
||||
inactiveFgColor: AppColors.textSecondary,
|
||||
animate: true,
|
||||
curve: Curves.easeInOut,
|
||||
onToggle: (index) {
|
||||
final val = (index == 1) ? 'Femenino' : 'Masculino';
|
||||
field.didChange(val);
|
||||
s.sexo = val;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildCompactSelector(
|
||||
context: context,
|
||||
title: 'Seguro',
|
||||
titleIcon: Icons.health_and_safety_outlined,
|
||||
isRequired: true,
|
||||
child: FormBuilderField<String>(
|
||||
name: 'seguro',
|
||||
initialValue: s.seguro,
|
||||
validator: FormBuilderValidators.required(errorText: 'Requerido'),
|
||||
builder: (FormFieldState<String> field) {
|
||||
final currentVal = field.value ?? s.seguro;
|
||||
final cleanVal = currentVal.trim().toLowerCase();
|
||||
final isNo = cleanVal == 'no' || cleanVal.contains('sin seguro') || cleanVal.contains('no asegurado');
|
||||
final initialIndex = isNo ? 1 : 0;
|
||||
return ToggleSwitch(
|
||||
key: ValueKey('seguro_$currentVal'),
|
||||
customWidths: const [116.0, 116.0],
|
||||
minHeight: 28.0,
|
||||
cornerRadius: 6.0,
|
||||
radiusStyle: true,
|
||||
fontSize: 10.5,
|
||||
iconSize: 12.0,
|
||||
centerText: true,
|
||||
initialLabelIndex: initialIndex,
|
||||
totalSwitches: 2,
|
||||
labels: const ['Con Seguro', 'Sin Seguro'],
|
||||
icons: const [Icons.check_rounded, Icons.close_rounded],
|
||||
activeBgColor: const [AppColors.primary],
|
||||
activeFgColor: Colors.white,
|
||||
inactiveBgColor: const Color(0xFFE2E8F0),
|
||||
inactiveFgColor: AppColors.textSecondary,
|
||||
animate: true,
|
||||
curve: Curves.easeInOut,
|
||||
onToggle: (index) {
|
||||
final val = (index == 1) ? 'Sin Seguro' : 'Con Seguro';
|
||||
field.didChange(val);
|
||||
s.seguro = val;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildCompactSelector(
|
||||
context: context,
|
||||
title: 'Estado Civil',
|
||||
titleIcon: Icons.favorite_border_rounded,
|
||||
child: FormBuilderField<String>(
|
||||
name: 'est_civil',
|
||||
initialValue: s.estCivil,
|
||||
builder: (FormFieldState<String> field) {
|
||||
const options = ['Casado', 'Soltero', 'Otros'];
|
||||
final currentVal = field.value ?? s.estCivil;
|
||||
int initialIndex = 1;
|
||||
if (currentVal.toLowerCase().contains('casad')) {
|
||||
initialIndex = 0;
|
||||
} else if (currentVal.toLowerCase().contains('otro')) {
|
||||
initialIndex = 2;
|
||||
}
|
||||
return ToggleSwitch(
|
||||
key: ValueKey('est_civil_$currentVal'),
|
||||
customWidths: const [86.0, 90.0, 76.0],
|
||||
minHeight: 28.0,
|
||||
cornerRadius: 6.0,
|
||||
radiusStyle: true,
|
||||
fontSize: 10.5,
|
||||
iconSize: 11.5,
|
||||
centerText: true,
|
||||
initialLabelIndex: initialIndex,
|
||||
totalSwitches: 3,
|
||||
labels: options,
|
||||
icons: const [Icons.people_alt_outlined, Icons.person_outline_rounded, Icons.more_horiz_rounded],
|
||||
activeBgColor: const [AppColors.primary],
|
||||
activeFgColor: Colors.white,
|
||||
inactiveBgColor: const Color(0xFFE2E8F0),
|
||||
inactiveFgColor: AppColors.textSecondary,
|
||||
animate: true,
|
||||
curve: Curves.easeInOut,
|
||||
onToggle: (index) {
|
||||
final selected = options[index ?? 1];
|
||||
field.didChange(selected);
|
||||
s.estCivil = selected;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: unifiedRowHeight,
|
||||
width: double.infinity,
|
||||
child: Observer(
|
||||
builder: (_) => ElevatedButton(
|
||||
onPressed: s.isSaving ? null : onSave,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00A65A),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 1,
|
||||
shadowColor: const Color(0xFF00A65A).withValues(alpha: 0.35),
|
||||
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: s.isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline_rounded, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
s.estadoEspera == 'en_consulta'
|
||||
? 'Finalizar Consulta'
|
||||
: (s.isEditing ? 'Guardar Cambios' : 'Guardar Paciente'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user