From 581adc85daebd637c713c931d1026d1f1fa1184d Mon Sep 17 00:00:00 2001 From: = Date: Fri, 2 Oct 2026 11:09:02 -0400 Subject: [PATCH] feat(patient): make antecedentes patologicos dropdowns editable with custom notes and options menu --- .../widgets/patient_clinical_tabs.dart | 209 +++++++++++++++--- test/features/patient_test.dart | 65 ++++++ 2 files changed, 249 insertions(+), 25 deletions(-) diff --git a/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart b/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart index 8f9a65d..58feebe 100644 --- a/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart +++ b/lib/features/patient/presentation/widgets/patient_clinical_tabs.dart @@ -122,7 +122,7 @@ class _PatientClinicalTabsState extends State ); } - InputDecoration _cleanDropdownDecoration(String label) { + static InputDecoration _cleanDropdownDecoration(String label) { return InputDecoration( labelText: label, floatingLabelBehavior: FloatingLabelBehavior.always, @@ -275,31 +275,12 @@ class _PatientClinicalTabsState extends State required List items, required ValueChanged onChanged, }) { - final list = items.contains(initialValue) - ? items - : (initialValue.isNotEmpty ? [initialValue, ...items] : items); - return FormBuilderDropdown( + return _EditableDropdownField( name: name, - initialValue: initialValue.isNotEmpty ? initialValue : null, - decoration: _cleanDropdownDecoration(label), - hint: const Text( - 'Seleccionar...', - style: TextStyle(fontSize: 13.5, color: Color(0xFF94A3B8)), - ), - style: const TextStyle(fontSize: 13.5, color: AppColors.textPrimary, fontWeight: FontWeight.w500), - onChanged: (v) { - if (v != null) onChanged(v); - }, - items: list.map((item) { - return DropdownMenuItem( - value: item, - child: Text( - item, - style: const TextStyle(fontSize: 13.5), - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), + label: label, + initialValue: initialValue, + items: items, + onChanged: onChanged, ); } @@ -937,3 +918,181 @@ class _PatientClinicalTabsState extends State ); } } + +class _EditableDropdownField extends StatefulWidget { + final String name; + final String label; + final String initialValue; + final List items; + final ValueChanged onChanged; + + const _EditableDropdownField({ + required this.name, + required this.label, + required this.initialValue, + required this.items, + required this.onChanged, + }); + + @override + State<_EditableDropdownField> createState() => _EditableDropdownFieldState(); +} + +class _EditableDropdownFieldState extends State<_EditableDropdownField> { + late final TextEditingController _controller; + late final FocusNode _focusNode; + final GlobalKey _fieldKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _focusNode = FocusNode(); + } + + @override + void didUpdateWidget(covariant _EditableDropdownField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue && _controller.text != widget.initialValue) { + _controller.value = TextEditingValue( + text: widget.initialValue, + selection: TextSelection.collapsed(offset: widget.initialValue.length), + ); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _showDropdownMenu(FormFieldState field) async { + final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; + if (overlay == null) return; + + final targetRect = Rect.fromPoints( + renderBox.localToGlobal(renderBox.size.bottomLeft(Offset.zero), ancestor: overlay).translate(0, 4), + renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero), ancestor: overlay).translate(0, 4), + ); + + final position = RelativeRect.fromRect( + targetRect, + Offset.zero & overlay.size, + ); + + final selected = await showMenu( + context: context, + position: position, + constraints: BoxConstraints( + minWidth: renderBox.size.width, + maxWidth: renderBox.size.width < 220 ? 220 : renderBox.size.width, + maxHeight: 280, + ), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Color(0xFFE2E8F0)), + ), + color: Colors.white, + items: widget.items.map((item) { + final isSelected = _controller.text.trim().toLowerCase() == item.trim().toLowerCase(); + return PopupMenuItem( + value: item, + height: 38, + child: Row( + children: [ + Expanded( + child: Text( + item, + style: TextStyle( + fontSize: 13.5, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + const Icon(Icons.check_rounded, size: 16, color: AppColors.primary), + ], + ), + ); + }).toList(), + ); + + if (selected != null) { + _controller.text = selected; + _controller.selection = TextSelection.collapsed(offset: selected.length); + field.didChange(selected); + widget.onChanged(selected); + setState(() {}); + _focusNode.requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + return FormBuilderField( + name: widget.name, + initialValue: widget.initialValue, + builder: (FormFieldState field) { + return TextField( + key: _fieldKey, + controller: _controller, + focusNode: _focusNode, + style: const TextStyle( + fontSize: 13.5, + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + decoration: _PatientClinicalTabsState._cleanDropdownDecoration(widget.label).copyWith( + hintText: 'Seleccionar...', + suffixIconConstraints: const BoxConstraints(minWidth: 40, minHeight: 40), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear_rounded, size: 16, color: Color(0xFF94A3B8)), + splashRadius: 16, + tooltip: 'Limpiar', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + onPressed: () { + _controller.clear(); + field.didChange(''); + widget.onChanged(''); + setState(() {}); + }, + ), + InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _showDropdownMenu(field), + child: const Padding( + padding: EdgeInsets.only(left: 4, right: 8, top: 8, bottom: 8), + child: Icon( + Icons.arrow_drop_down_rounded, + size: 24, + color: Color(0xFF64748B), + ), + ), + ), + ], + ), + ), + onChanged: (val) { + field.didChange(val); + widget.onChanged(val); + setState(() {}); + }, + ); + }, + ); + } +} + diff --git a/test/features/patient_test.dart b/test/features/patient_test.dart index 9b755ee..81df5e1 100644 --- a/test/features/patient_test.dart +++ b/test/features/patient_test.dart @@ -6,6 +6,7 @@ import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model. import 'package:hidalyx_drluiscastro/features/patient/data/services/patient_service.dart'; import 'package:hidalyx_drluiscastro/features/patient/presentation/store/patient_store.dart'; import 'package:hidalyx_drluiscastro/features/patient/presentation/views/patient_modal_view.dart'; +import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/patient_clinical_tabs.dart'; import 'package:hidalyx_drluiscastro/features/patient/presentation/widgets/patient_demographics_form.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:toggle_switch/toggle_switch.dart'; @@ -393,5 +394,69 @@ void main() { expect(success, isTrue); expect(store.estadoEspera, 'atendido'); }); + + testWidgets('Antec. patologicos fields are editable and allow custom text or clear', (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + final store = PatientStore(patientService: MockPatientService()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PatientClinicalTabs(store: store), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on the 'Antec. patológicos' tab + await tester.tap(find.text('Antec. patológicos')); + await tester.pumpAndSettle(); + + // Verify that section and labels exist + expect(find.text('Antecedentes Personales Patológicos'), findsOneWidget); + expect(find.text('Alergias'), findsOneWidget); + expect(find.text('Infancia'), findsOneWidget); + + // Find the text field for 'Alergias' and enter custom text + final alergiasField = find.ancestor( + of: find.text('Alergias'), + matching: find.byType(TextField), + ); + expect(alergiasField, findsOneWidget); + + await tester.enterText(alergiasField, 'Ibuprofeno y mariscos'); + await tester.pumpAndSettle(); + + expect(store.datAlerg, 'Ibuprofeno y mariscos'); + + // Test clear button specifically for Alergias + final alergiasClear = find.descendant( + of: alergiasField, + matching: find.byTooltip('Limpiar'), + ); + expect(alergiasClear, findsOneWidget); + await tester.tap(alergiasClear); + await tester.pumpAndSettle(); + expect(store.datAlerg, ''); + + // Test opening dropdown menu and selecting an item + final dropdownArrow = find.descendant( + of: alergiasField, + matching: find.byIcon(Icons.arrow_drop_down_rounded), + ); + expect(dropdownArrow, findsOneWidget); + await tester.tap(dropdownArrow); + await tester.pumpAndSettle(); + + // Tap 'Penicilina' from the menu + await tester.tap(find.text('Penicilina').last); + await tester.pumpAndSettle(); + expect(store.datAlerg, 'Penicilina'); + + // Test appending text to the selected option + await tester.enterText(alergiasField, 'Penicilina (shock anafiláctico)'); + await tester.pumpAndSettle(); + expect(store.datAlerg, 'Penicilina (shock anafiláctico)'); + }); }); }