From 1152168ea6d947da2b7235097427771cee653188 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 22 Sep 2026 08:38:19 -0400 Subject: [PATCH] feat(prescription): add intelligent medical toolbar with numbering, dosage shortcuts and quick chips --- .../views/prescription_modal_view.dart | 377 ++++++++++++++++-- test/features/prescription_test.dart | 47 +++ 2 files changed, 383 insertions(+), 41 deletions(-) diff --git a/lib/features/prescription/presentation/views/prescription_modal_view.dart b/lib/features/prescription/presentation/views/prescription_modal_view.dart index ecbbd1c..6cc0e0f 100644 --- a/lib/features/prescription/presentation/views/prescription_modal_view.dart +++ b/lib/features/prescription/presentation/views/prescription_modal_view.dart @@ -47,10 +47,9 @@ class _PrescriptionModalViewState extends State { super.dispose(); } - void _insertBullet() { + void _insertText(String textToInsert) { final text = _prescriptionController.text; final selection = _prescriptionController.selection; - final bullet = text.isEmpty || text.endsWith('\n') ? '• ' : '\n• '; int start = selection.start; int end = selection.end; @@ -59,8 +58,8 @@ class _PrescriptionModalViewState extends State { end = text.length; } - final newText = text.replaceRange(start, end, bullet); - final newOffset = start + bullet.length; + final newText = text.replaceRange(start, end, textToInsert); + final newOffset = start + textToInsert.length; _prescriptionController.value = TextEditingValue( text: newText, @@ -69,6 +68,44 @@ class _PrescriptionModalViewState extends State { _prescriptionFocusNode.requestFocus(); } + void _insertBullet() { + final text = _prescriptionController.text; + final bullet = text.isEmpty || text.endsWith('\n') ? '• ' : '\n• '; + _insertText(bullet); + } + + void _insertNextNumber() { + final text = _prescriptionController.text; + final matches = RegExp(r'(?:^|\n)\s*(\d+)\.').allMatches(text); + int nextNumber = 1; + if (matches.isNotEmpty) { + final lastNum = int.tryParse(matches.last.group(1) ?? '0') ?? 0; + nextNumber = lastNum + 1; + } + final prefix = text.isEmpty || text.endsWith('\n') ? '$nextNumber. ' : '\n$nextNumber. '; + _insertText(prefix); + } + + void _insertSubIndication() { + final text = _prescriptionController.text; + final prefix = text.isEmpty || text.endsWith('\n') ? ' - ' : '\n - '; + _insertText(prefix); + } + + void _insertDosageQuick(String dosage) { + final text = _prescriptionController.text; + final selection = _prescriptionController.selection; + int start = selection.start; + String toInsert = dosage; + if (start > 0 && text.isNotEmpty && start <= text.length) { + final prevChar = text[start - 1]; + if (prevChar != ' ' && prevChar != '\n') { + toInsert = ' $dosage'; + } + } + _insertText(toInsert); + } + Future _handlePrint() async { final patient = _patientController.text.trim(); final prescription = _prescriptionController.text.trim(); @@ -353,57 +390,230 @@ class _PrescriptionModalViewState extends State { ), const SizedBox(height: 16), - // Barra de herramientas de redacción + // Sección de Medicamentos y Redacción Inteligente Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( - 'Medicamentos y Posología:', + 'Medicamentos e Indicaciones:', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), - TextButton.icon( - onPressed: _insertBullet, - icon: const Icon(Icons.format_list_bulleted_rounded, size: 16), - label: const Text('Insertar viñeta (•)', style: TextStyle(fontSize: 12)), - style: TextButton.styleFrom( - foregroundColor: AppColors.primary, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - visualDensity: VisualDensity.compact, - ), + ValueListenableBuilder( + valueListenable: _prescriptionController, + builder: (context, val, _) { + if (val.text.isEmpty) return const SizedBox.shrink(); + return InkWell( + onTap: () { + _prescriptionController.clear(); + }, + borderRadius: BorderRadius.circular(4), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Row( + children: [ + Icon(Icons.clear_all_rounded, size: 14, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'Limpiar lienzo', + style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + }, ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), - // Lienzo de prescripción - TextField( - controller: _prescriptionController, - focusNode: _prescriptionFocusNode, - maxLines: 12, - minLines: 8, - style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: '1. Nombre del medicamento, concentración\n - Dosis y frecuencia (ej. Tomar 1 tableta cada 8 horas por 7 días)\n - Indicaciones especiales (con comidas, en ayunas, etc.)\n\n2. Siguiente medicamento...', - hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppColors.primary, width: 1.5), - ), + // Editor de Receta con Toolbar Inteligente Integrada + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFCBD5E1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Barra de herramientas superior + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + borderRadius: BorderRadius.vertical(top: Radius.circular(9)), + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + // 1. Número consecutivo + _ToolbarActionBtn( + icon: Icons.format_list_numbered_rounded, + label: '1. Número', + tooltip: 'Agregar siguiente número consecutivo (1., 2., ...)', + onTap: _insertNextNumber, + ), + // Viñeta (Mantiene texto exacto para compatibilidad con tests) + _ToolbarActionBtn( + icon: Icons.format_list_bulleted_rounded, + label: 'Insertar viñeta (•)', + tooltip: 'Insertar viñeta de indicación secundaria', + onTap: _insertBullet, + ), + // Sub-indicación (Dosis) + _ToolbarActionBtn( + icon: Icons.subdirectory_arrow_right_rounded, + label: '↳ Dosis', + tooltip: 'Indentar indicación de dosis (- )', + onTap: _insertSubIndication, + ), + // Separador vertical + Container( + height: 18, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 2), + color: const Color(0xFFCBD5E1), + ), + // Menú desplegable de Posologías frecuentes + PopupMenuButton( + tooltip: 'Plantillas y frases de posología rápida', + onSelected: _insertDosageQuick, + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + itemBuilder: (context) => [ + const PopupMenuItem( + enabled: false, + height: 28, + child: Text( + '-- HORARIOS FRECUENTES --', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + ), + const PopupMenuItem(value: 'cada 8 horas', height: 32, child: Text('cada 8 horas', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'cada 12 horas', height: 32, child: Text('cada 12 horas', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'cada 24 horas', height: 32, child: Text('cada 24 horas', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: '1 vez al día (en la mañana)', height: 32, child: Text('1 vez al día (en la mañana)', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: '1 vez al día (en la noche)', height: 32, child: Text('1 vez al día (en la noche)', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'antes de acostarse', height: 32, child: Text('antes de acostarse', style: TextStyle(fontSize: 12.5))), + const PopupMenuDivider(height: 8), + const PopupMenuItem( + enabled: false, + height: 28, + child: Text( + '-- DURACIÓN TRATAMIENTO --', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + ), + const PopupMenuItem(value: 'por 3 días', height: 32, child: Text('por 3 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'por 5 días', height: 32, child: Text('por 5 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'por 7 días', height: 32, child: Text('por 7 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'por 10 días', height: 32, child: Text('por 10 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'por 14 días', height: 32, child: Text('por 14 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'por 30 días', height: 32, child: Text('por 30 días', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'según dolor o necesidad (SOS)', height: 32, child: Text('según dolor o necesidad (SOS)', style: TextStyle(fontSize: 12.5))), + const PopupMenuDivider(height: 8), + const PopupMenuItem( + enabled: false, + height: 28, + child: Text( + '-- INDICACIONES ESPECIALES --', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + ), + const PopupMenuItem(value: 'con alimentos', height: 32, child: Text('con alimentos', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'en ayunas (30 min antes de desayunar)', height: 32, child: Text('en ayunas (30 min antes de desayunar)', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'con abundante agua', height: 32, child: Text('con abundante agua', style: TextStyle(fontSize: 12.5))), + const PopupMenuItem(value: 'no suspender sin indicación médica', height: 32, child: Text('no suspender sin indicación médica', style: TextStyle(fontSize: 12.5))), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timer_outlined, size: 14, color: AppColors.primary), + SizedBox(width: 4), + Text( + 'Posología ▼', + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Chips de inserción de 1 toque (frecuencias y formatos más comunes) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const Text( + 'Rápido: ', + style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.w700, color: AppColors.textMuted), + ), + _QuickChip(label: '1 tableta', onTap: () => _insertDosageQuick('1 tableta')), + _QuickChip(label: '1 cápsula', onTap: () => _insertDosageQuick('1 cápsula')), + _QuickChip(label: '1 ampolla', onTap: () => _insertDosageQuick('1 ampolla')), + _QuickChip(label: 'c/8h', onTap: () => _insertDosageQuick('cada 8 horas')), + _QuickChip(label: 'c/12h', onTap: () => _insertDosageQuick('cada 12 horas')), + _QuickChip(label: 'c/24h', onTap: () => _insertDosageQuick('cada 24 horas')), + _QuickChip(label: 'x 7 días', onTap: () => _insertDosageQuick('por 7 días')), + _QuickChip(label: 'x 14 días', onTap: () => _insertDosageQuick('por 14 días')), + _QuickChip(label: 'En ayunas', onTap: () => _insertDosageQuick('en ayunas')), + _QuickChip(label: 'Con agua', onTap: () => _insertDosageQuick('con abundante agua')), + ], + ), + ), + ), + + // Lienzo de prescripción + TextField( + controller: _prescriptionController, + focusNode: _prescriptionFocusNode, + maxLines: 12, + minLines: 8, + style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '1. Ciprofloxacina 500mg (14 tabletas)\n - Tomar 1 tableta cada 12 horas por 7 días con abundante agua.\n\n2. Tamsulosina 0.4mg (30 cápsulas)\n - Tomar 1 cápsula cada 24 horas después de la cena.', + hintStyle: TextStyle(color: Color(0xFF94A3B8), fontSize: 12.5), + border: InputBorder.none, + contentPadding: EdgeInsets.all(14), + ), + ), + ], ), ), ], @@ -414,3 +624,88 @@ class _PrescriptionModalViewState extends State { ); } } + +class _ToolbarActionBtn extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final VoidCallback onTap; + + const _ToolbarActionBtn({ + required this.icon, + required this.label, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppColors.primary), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _QuickChip extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const _QuickChip({ + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 5), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFCBD5E1)), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/test/features/prescription_test.dart b/test/features/prescription_test.dart index e4cbd8f..78fcf7c 100644 --- a/test/features/prescription_test.dart +++ b/test/features/prescription_test.dart @@ -101,6 +101,53 @@ void main() { expect(find.text('• '), findsOneWidget); }); + + testWidgets('inserts sequential numbers and sub-indication dosis', (tester) async { + final configStore = ConfigStore(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PrescriptionModalView( + patientName: 'CARLOS ARIAS', + configStore: configStore, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final numberBtn = find.text('1. Número'); + expect(numberBtn, findsOneWidget); + await tester.tap(numberBtn); + await tester.pump(); + expect(find.text('1. '), findsOneWidget); + + // Tap again to get next sequential number + await tester.tap(numberBtn); + await tester.pump(); + expect(find.text('1. \n2. '), findsOneWidget); + + // Insert dosage sub-indication + final subIndicationBtn = find.text('↳ Dosis'); + expect(subIndicationBtn, findsOneWidget); + await tester.tap(subIndicationBtn); + await tester.pump(); + expect(find.text('1. \n2. \n - '), findsOneWidget); + + // Tap quick dosage chip + final chip = find.text('1 tableta'); + expect(chip, findsOneWidget); + await tester.tap(chip); + await tester.pump(); + expect(find.text('1. \n2. \n - 1 tableta'), findsOneWidget); + + // Test clear button + final clearBtn = find.text('Limpiar lienzo'); + expect(clearBtn, findsOneWidget); + await tester.tap(clearBtn); + await tester.pump(); + expect(find.text('Limpiar lienzo'), findsNothing); + }); }); group('PrescriptionPdfService Tests', () {