diff --git a/lib/features/prescription/presentation/views/prescription_modal_view.dart b/lib/features/prescription/presentation/views/prescription_modal_view.dart index 1c499f4..f6d68d2 100644 --- a/lib/features/prescription/presentation/views/prescription_modal_view.dart +++ b/lib/features/prescription/presentation/views/prescription_modal_view.dart @@ -72,25 +72,44 @@ class _PrescriptionModalViewState extends State { void _insertBullet() { final text = _prescriptionController.text; - final bullet = text.isEmpty || text.endsWith('\n') ? '• ' : '\n• '; + final selection = _prescriptionController.selection; + int start = selection.start; + if (start < 0 || start > text.length) start = text.length; + + final textBefore = text.substring(0, start); + final needNewline = textBefore.isNotEmpty && !textBefore.endsWith('\n'); + final bullet = needNewline ? '\n• ' : '• '; _insertText(bullet); } void _insertNextNumber() { final text = _prescriptionController.text; + final selection = _prescriptionController.selection; + int start = selection.start; + if (start < 0 || start > text.length) start = text.length; + 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. '; + + final textBefore = text.substring(0, start); + final needNewline = textBefore.isNotEmpty && !textBefore.endsWith('\n'); + final prefix = needNewline ? '\n$nextNumber. ' : '$nextNumber. '; _insertText(prefix); } void _insertSubIndication() { final text = _prescriptionController.text; - final prefix = text.isEmpty || text.endsWith('\n') ? ' - ' : '\n - '; + final selection = _prescriptionController.selection; + int start = selection.start; + if (start < 0 || start > text.length) start = text.length; + + final textBefore = text.substring(0, start); + final needNewline = textBefore.isNotEmpty && !textBefore.endsWith('\n'); + final prefix = needNewline ? '\n - ' : ' - '; _insertText(prefix); } @@ -180,12 +199,15 @@ class _PrescriptionModalViewState extends State { @override Widget build(BuildContext context) { final formattedDate = DateFormat('dd/MM/yyyy').format(DateTime.now()); + final mediaQuery = MediaQuery.sizeOf(context); + final modalHeight = (mediaQuery.height * 0.90).clamp(650.0, 780.0); + final modalWidth = (mediaQuery.width * 0.90).clamp(840.0, 920.0); return Observer( builder: (_) { return MedicalModalDialog( - maxWidth: 820, - maxHeight: 650, + maxWidth: modalWidth, + maxHeight: modalHeight, title: 'Emisión de Receta Médica', titleIcon: Container( padding: const EdgeInsets.all(8), @@ -292,320 +314,168 @@ class _PrescriptionModalViewState extends State { ], ), ), - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Membrete Informativo del Médico (Vista preliminar institucional) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: Column( - children: [ - Text( - _configStore.nombreDoctor.isNotEmpty - ? _configStore.nombreDoctor - : 'Dr. Luis A. Castro Sosa', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - letterSpacing: -0.3, - ), - ), - if (_configStore.especialidad.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _configStore.especialidad.toUpperCase(), - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w600, - color: AppColors.primary, - letterSpacing: 0.5, - ), - ), - ], - if (_configStore.descripEspecialidad.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _configStore.descripEspecialidad.toUpperCase(), - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 10, - color: AppColors.textMuted, - ), - ), - ], - const SizedBox(height: 4), - Text( - 'Fecha: $formattedDate', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(height: 16), + child: LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxHeight < 420; - // Campo Paciente - Row( + final editorWidget = 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: 10, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF00A65A).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + 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: const Text( - 'Rx', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: Color(0xFF00A65A), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _patientController, - decoration: InputDecoration( - labelText: 'Paciente', - hintText: 'Nombre del paciente', - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.always, - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - 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), - ), - ), - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Sección de Medicamentos y Redacción Inteligente - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Medicamentos e Indicaciones:', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - ValueListenableBuilder( - valueListenable: _prescriptionController, - builder: (context, val, _) { - if (val.text.isEmpty) return const SizedBox.shrink(); - return InkWell( - onTap: () { - _prescriptionController.clear(); + child: ScrollConfiguration( + behavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, }, - 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: 8), - - // 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: ScrollConfiguration( - behavior: const MaterialScrollBehavior().copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - PointerDeviceKind.trackpad, - PointerDeviceKind.stylus, - }, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: Row( - 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, - ), - const SizedBox(width: 6), - // 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, - ), - const SizedBox(width: 6), - // Sub-indicación (Dosis) - _ToolbarActionBtn( - icon: Icons.subdirectory_arrow_right_rounded, - label: '↳ Dosis', - tooltip: 'Indentar indicación de dosis (- )', - onTap: _insertSubIndication, - ), - const SizedBox(width: 6), - // Separador vertical - Container( - height: 18, - width: 1, - margin: const EdgeInsets.symmetric(horizontal: 2), - color: const Color(0xFFCBD5E1), - ), - const SizedBox(width: 6), - // Botón alternador del panel de posologías - _ToolbarActionBtn( - icon: Icons.tune_rounded, - label: _showPosologyPanel ? 'Ocultar posologías ▲' : '⏱ Posologías rápidas ▼', - tooltip: _showPosologyPanel - ? 'Ocultar el panel de atajos de posología' - : 'Mostrar atajos de horario, duración e indicaciones', - isActive: _showPosologyPanel, - onTap: () { - setState(() { - _showPosologyPanel = !_showPosologyPanel; - }); - }, - ), - ], - ), - ), - ), - ), - - // Panel Desplegable de Posología Categorizada - if (_showPosologyPanel) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: const BoxDecoration( - color: Color(0xFFF8FAFC), - border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( children: [ - // Fila 1: Horarios / Frecuencia - _buildPosologyCategoryRow( - badgeLabel: '🕒 Horario', - badgeBgColor: const Color(0xFFEFF6FF), - badgeTextColor: const Color(0xFF1D4ED8), - items: const [ - 'cada 8 horas', - 'cada 12 horas', - 'cada 24 horas', - '1 vez al día (mañana)', - '1 vez al día (noche)', - 'al acostarse', - ], + // 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, ), - const SizedBox(height: 6), - // Fila 2: Duración - _buildPosologyCategoryRow( - badgeLabel: '📅 Duración', - badgeBgColor: const Color(0xFFF0FDF4), - badgeTextColor: const Color(0xFF15803D), - items: const [ - 'por 3 días', - 'por 5 días', - 'por 7 días', - 'por 10 días', - 'por 14 días', - 'por 30 días', - 'SOS (según dolor)', - ], + const SizedBox(width: 6), + // 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, ), - const SizedBox(height: 6), - // Fila 3: Forma e Indicación Especial - _buildPosologyCategoryRow( - badgeLabel: '💡 Indicación', - badgeBgColor: const Color(0xFFFFFBEB), - badgeTextColor: const Color(0xFFB45309), - items: const [ - '1 tableta', - '1 cápsula', - '1 ampolla', - 'con alimentos', - 'en ayunas (30 min antes)', - 'con abundante agua', - 'no suspender', - ], + const SizedBox(width: 6), + // Sub-indicación (Dosis) + _ToolbarActionBtn( + icon: Icons.subdirectory_arrow_right_rounded, + label: '↳ Dosis', + tooltip: 'Indentar indicación de dosis (- )', + onTap: _insertSubIndication, + ), + const SizedBox(width: 6), + // Separador vertical + Container( + height: 18, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 2), + color: const Color(0xFFCBD5E1), + ), + const SizedBox(width: 6), + // Botón alternador del panel de posologías + _ToolbarActionBtn( + icon: Icons.tune_rounded, + label: _showPosologyPanel ? 'Ocultar posologías ▲' : '⏱ Posologías rápidas ▼', + tooltip: _showPosologyPanel + ? 'Ocultar el panel de atajos de posología' + : 'Mostrar atajos de horario, duración e indicaciones', + isActive: _showPosologyPanel, + onTap: () { + setState(() { + _showPosologyPanel = !_showPosologyPanel; + }); + }, ), ], ), ), + ), + ), - // Lienzo de prescripción + // Panel Desplegable de Posología Categorizada + if (_showPosologyPanel) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Fila 1: Horarios / Frecuencia + _buildPosologyCategoryRow( + badgeLabel: '🕒 Horario', + badgeBgColor: const Color(0xFFEFF6FF), + badgeTextColor: const Color(0xFF1D4ED8), + items: const [ + 'cada 8 horas', + 'cada 12 horas', + 'cada 24 horas', + '1 vez al día (mañana)', + '1 vez al día (noche)', + 'al acostarse', + ], + ), + const SizedBox(height: 6), + // Fila 2: Duración + _buildPosologyCategoryRow( + badgeLabel: '📅 Duración', + badgeBgColor: const Color(0xFFF0FDF4), + badgeTextColor: const Color(0xFF15803D), + items: const [ + 'por 3 días', + 'por 5 días', + 'por 7 días', + 'por 10 días', + 'por 14 días', + 'por 30 días', + 'SOS (según dolor)', + ], + ), + const SizedBox(height: 6), + // Fila 3: Forma e Indicación Especial + _buildPosologyCategoryRow( + badgeLabel: '💡 Indicación', + badgeBgColor: const Color(0xFFFFFBEB), + badgeTextColor: const Color(0xFFB45309), + items: const [ + '1 tableta', + '1 cápsula', + '1 ampolla', + 'con alimentos', + 'en ayunas (30 min antes)', + 'con abundante agua', + 'no suspender', + ], + ), + ], + ), + ), + + // Lienzo de prescripción + if (isCompact) TextField( controller: _prescriptionController, focusNode: _prescriptionFocusNode, - maxLines: 12, - minLines: 7, + maxLines: 10, + minLines: 5, 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.', @@ -613,12 +483,200 @@ class _PrescriptionModalViewState extends State { border: InputBorder.none, contentPadding: EdgeInsets.all(14), ), + ) + else + Expanded( + child: TextField( + controller: _prescriptionController, + focusNode: _prescriptionFocusNode, + maxLines: null, + minLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + 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), + ), + ), + ), + ], + ), + ); + + final bodyContent = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Membrete Informativo del Médico (Vista preliminar institucional) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + _configStore.nombreDoctor.isNotEmpty + ? _configStore.nombreDoctor + : 'Dr. Luis A. Castro Sosa', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + if (_configStore.especialidad.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _configStore.especialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + if (_configStore.descripEspecialidad.isNotEmpty) ...[ + const SizedBox(height: 1), + Text( + _configStore.descripEspecialidad.toUpperCase(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 9.5, + color: AppColors.textMuted, + ), + ), + ], + const SizedBox(height: 3), + Text( + 'Fecha: $formattedDate', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + + // Campo Paciente + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF00A65A).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Rx', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Color(0xFF00A65A), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _patientController, + decoration: InputDecoration( + labelText: 'Paciente', + hintText: 'Nombre del paciente', + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.always, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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), + ), + ), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), ), ], ), - ), - ], - ), + const SizedBox(height: 12), + + // Sección de Medicamentos y Redacción Inteligente + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Medicamentos e Indicaciones:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + 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: 8), + + // Editor de Receta (Expanded para rellenar toda la altura o scrollable en pantallas pequeñas) + if (isCompact) editorWidget else Expanded(child: editorWidget), + ], + ); + + if (isCompact) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: bodyContent, + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), + child: bodyContent, + ); + }, ), ); }, diff --git a/test/features/prescription_test.dart b/test/features/prescription_test.dart index 52b2152..c549acd 100644 --- a/test/features/prescription_test.dart +++ b/test/features/prescription_test.dart @@ -79,7 +79,7 @@ void main() { expect(find.text('ESC para salir'), findsOneWidget); }); - testWidgets('inserts bullet when clicking viñeta button', (tester) async { + testWidgets('inserts bullet when clicking viñeta button and jumps to new line if text has content', (tester) async { final configStore = ConfigStore(); await tester.pumpWidget( MaterialApp( @@ -100,6 +100,18 @@ void main() { await tester.pump(); expect(find.text('• '), findsOneWidget); + + // Now enter text at end of line and test auto-newline + final prescriptionField = find.byWidgetPredicate( + (w) => w is TextField && (w.decoration?.hintText?.contains('Ciprofloxacina') ?? false), + ); + await tester.enterText(prescriptionField, '1. Ciprofloxacina 500mg'); + await tester.pump(); + + await tester.tap(bulletBtn); + await tester.pump(); + + expect(find.text('1. Ciprofloxacina 500mg\n• '), findsOneWidget); }); testWidgets('inserts sequential numbers and sub-indication dosis', (tester) async {