2026-09-22 10:16:43 -04:00
import 'dart:ui' ;
2026-09-21 16:33:51 -04:00
import 'package:flutter/material.dart' ;
import 'package:flutter_mobx/flutter_mobx.dart' ;
import 'package:intl/intl.dart' ;
import '../../../../core/theme/app_colors.dart' ;
import '../../../../core/widgets/medical_modal.dart' ;
import '../../../config/presentation/store/config_store.dart' ;
import '../../services/prescription_pdf_service.dart' ;
class PrescriptionModalView extends StatefulWidget {
final String patientName ;
final ConfigStore ? configStore ;
const PrescriptionModalView ({
super . key ,
required this . patientName ,
this . configStore ,
});
@ override
State < PrescriptionModalView > createState () => _PrescriptionModalViewState ();
}
class _PrescriptionModalViewState extends State < PrescriptionModalView > {
late final TextEditingController _patientController ;
late final TextEditingController _prescriptionController ;
late final FocusNode _prescriptionFocusNode ;
late final ConfigStore _configStore ;
bool _isPrinting = false ;
String ? _statusMessage ;
bool _hasError = false ;
2026-09-22 10:16:43 -04:00
bool _showPosologyPanel = false ;
2026-09-21 16:33:51 -04:00
@ override
void initState () {
super . initState ();
_patientController = TextEditingController ( text: widget . patientName );
_prescriptionController = TextEditingController ();
_prescriptionFocusNode = FocusNode ();
_configStore = widget . configStore ?? ConfigStore ();
_configStore . loadConfig ();
}
@ override
void dispose () {
_patientController . dispose ();
_prescriptionController . dispose ();
_prescriptionFocusNode . dispose ();
super . dispose ();
}
2026-09-22 08:38:19 -04:00
void _insertText ( String textToInsert ) {
2026-09-21 16:33:51 -04:00
final text = _prescriptionController . text ;
final selection = _prescriptionController . selection ;
int start = selection . start ;
int end = selection . end ;
if ( start < 0 || end < 0 ) {
start = text . length ;
end = text . length ;
}
2026-09-22 08:38:19 -04:00
final newText = text . replaceRange ( start , end , textToInsert );
final newOffset = start + textToInsert . length ;
2026-09-21 16:33:51 -04:00
_prescriptionController . value = TextEditingValue (
text: newText ,
selection: TextSelection . collapsed ( offset: newOffset ),
);
_prescriptionFocusNode . requestFocus ();
}
2026-09-22 08:38:19 -04:00
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 );
}
2026-09-22 10:16:43 -04:00
Widget _buildPosologyCategoryRow ({
required String badgeLabel ,
required Color badgeBgColor ,
required Color badgeTextColor ,
required List < String > items ,
}) {
return _PosologyCategoryRowWidget (
badgeLabel: badgeLabel ,
badgeBgColor: badgeBgColor ,
badgeTextColor: badgeTextColor ,
items: items ,
onSelected: _insertDosageQuick ,
);
}
2026-09-21 16:33:51 -04:00
Future < void > _handlePrint () async {
final patient = _patientController . text . trim ();
final prescription = _prescriptionController . text . trim ();
if ( patient . isEmpty ) {
setState (() {
_statusMessage = 'Debe indicar el nombre del paciente.' ;
_hasError = true ;
});
return ;
}
if ( prescription . isEmpty ) {
setState (() {
_statusMessage = 'Debe redactar los medicamentos e indicaciones de la receta.' ;
_hasError = true ;
});
return ;
}
setState (() {
_isPrinting = true ;
_statusMessage = 'Generando receta médica en formato oficial...' ;
_hasError = false ;
});
try {
await PrescriptionPdfService . printPrescription (
doctorName: _configStore . nombreDoctor ,
specialty: _configStore . especialidad ,
subspecialty: _configStore . descripEspecialidad ,
generalContact: _configStore . descripGeneral ,
patientName: patient ,
prescriptionText: prescription ,
);
if ( mounted ) {
setState (() {
_isPrinting = false ;
_statusMessage = 'Receta generada con éxito.' ;
_hasError = false ;
});
}
} catch ( e ) {
if ( mounted ) {
setState (() {
_isPrinting = false ;
_statusMessage = 'Error al generar la receta: $ e ' ;
_hasError = true ;
});
}
}
}
@ override
Widget build ( BuildContext context ) {
final formattedDate = DateFormat ( 'dd/MM/yyyy' ). format ( DateTime . now ());
return Observer (
builder: ( _ ) {
return MedicalModalDialog (
maxWidth: 820 ,
maxHeight: 650 ,
title: 'Emisión de Receta Médica' ,
titleIcon: Container (
padding: const EdgeInsets . all ( 8 ),
decoration: BoxDecoration (
color: const Color ( 0xFF00A65A ). withValues ( alpha: 0.12 ),
borderRadius: BorderRadius . circular ( 10 ),
),
child: const Icon (
Icons . medication_rounded ,
color: Color ( 0xFF00A65A ),
size: 20 ,
),
),
subtitle: const Text (
'Redacte la prescripción para imprimir en talonario media carta institucional' ,
style: TextStyle ( fontSize: 12 , color: AppColors . textMuted ),
),
bottomBar: Container (
padding: const EdgeInsets . symmetric ( horizontal: 20 , vertical: 12 ),
decoration: const BoxDecoration (
color: Color ( 0xFFF8FAFC ),
border: Border ( top: BorderSide ( color: Color ( 0xFFE2E8F0 ))),
),
child: Row (
children: [
Container (
padding: const EdgeInsets . all ( 4 ),
decoration: BoxDecoration (
color: _hasError
? AppColors . error . withValues ( alpha: 0.12 )
: const Color ( 0xFF00A65A ). withValues ( alpha: 0.12 ),
shape: BoxShape . circle ,
),
child: Icon (
_hasError
? Icons . error_outline_rounded
: Icons . check_circle_outline_rounded ,
size: 15 ,
color: _hasError ? AppColors . error : const Color ( 0xFF00A65A ),
),
),
const SizedBox ( width: 8 ),
Expanded (
child: Text (
_statusMessage ??
( _isPrinting
? 'Preparando documento para impresión...'
: 'Listo para emitir la receta médica' ),
style: TextStyle (
fontSize: 12 ,
fontWeight: FontWeight . w600 ,
color: _hasError ? AppColors . error : AppColors . textSecondary ,
),
),
),
Container (
padding: const EdgeInsets . symmetric ( horizontal: 8 , vertical: 4 ),
decoration: BoxDecoration (
color: Colors . white ,
borderRadius: BorderRadius . circular ( 6 ),
border: Border . all ( color: const Color ( 0xFFE2E8F0 )),
),
child: const Row (
mainAxisSize: MainAxisSize . min ,
children: [
Icon ( Icons . keyboard_outlined , size: 12 , color: AppColors . textMuted ),
SizedBox ( width: 4 ),
Text (
'ESC para salir' ,
style: TextStyle (
fontSize: 11 ,
color: AppColors . textMuted ,
fontWeight: FontWeight . w500 ,
),
),
],
),
),
const SizedBox ( width: 12 ),
ElevatedButton . icon (
onPressed: _isPrinting ? null : _handlePrint ,
icon: _isPrinting
? const SizedBox (
width: 16 ,
height: 16 ,
child: CircularProgressIndicator (
strokeWidth: 2 ,
color: Colors . white ,
),
)
: const Icon ( Icons . print_rounded , size: 16 ),
label: Text (
_isPrinting ? 'Generando...' : 'Crear / Imprimir Receta' ,
style: const TextStyle ( fontWeight: FontWeight . w600 , fontSize: 12.5 ),
),
style: ElevatedButton . styleFrom (
backgroundColor: const Color ( 0xFF00A65A ),
foregroundColor: Colors . white ,
padding: const EdgeInsets . symmetric ( horizontal: 16 , vertical: 12 ),
shape: RoundedRectangleBorder ( borderRadius: BorderRadius . circular ( 8 )),
elevation: 0 ,
),
),
],
),
),
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 ),
// 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: 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 ),
2026-09-22 08:38:19 -04:00
// Sección de Medicamentos y Redacción Inteligente
2026-09-21 16:33:51 -04:00
Row (
mainAxisAlignment: MainAxisAlignment . spaceBetween ,
children: [
const Text (
2026-09-22 08:38:19 -04:00
'Medicamentos e Indicaciones:' ,
2026-09-21 16:33:51 -04:00
style: TextStyle (
fontSize: 13 ,
fontWeight: FontWeight . w700 ,
color: AppColors . textPrimary ,
),
),
2026-09-22 08:38:19 -04:00
ValueListenableBuilder < TextEditingValue > (
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 ),
),
],
),
),
);
},
2026-09-21 16:33:51 -04:00
),
],
),
2026-09-22 08:38:19 -04:00
const SizedBox ( height: 8 ),
2026-09-21 16:33:51 -04:00
2026-09-22 08:38:19 -04:00
// 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 ))),
),
2026-09-22 10:16:43 -04:00
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 ,
2026-09-22 08:38:19 -04:00
),
2026-09-22 10:16:43 -04:00
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 ,
2026-09-22 08:38:19 -04:00
),
2026-09-22 10:16:43 -04:00
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 ;
});
},
2026-09-22 08:38:19 -04:00
),
],
),
),
),
),
2026-09-22 10:16:43 -04:00
// 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' ,
],
),
],
),
),
2026-09-22 08:38:19 -04:00
// Lienzo de prescripción
TextField (
controller: _prescriptionController ,
focusNode: _prescriptionFocusNode ,
maxLines: 12 ,
2026-09-22 10:16:43 -04:00
minLines: 7 ,
2026-09-22 08:38:19 -04:00
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\n 2. 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 ),
),
),
],
2026-09-21 16:33:51 -04:00
),
),
],
),
),
);
},
);
}
}
2026-09-22 08:38:19 -04:00
class _ToolbarActionBtn extends StatelessWidget {
final IconData icon ;
final String label ;
final String tooltip ;
final VoidCallback onTap ;
2026-09-22 10:16:43 -04:00
final bool isActive ;
2026-09-22 08:38:19 -04:00
const _ToolbarActionBtn ({
required this . icon ,
required this . label ,
required this . tooltip ,
required this . onTap ,
2026-09-22 10:16:43 -04:00
this . isActive = false ,
2026-09-22 08:38:19 -04:00
});
@ 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 (
2026-09-22 10:16:43 -04:00
color: isActive ? AppColors . primary . withValues ( alpha: 0.08 ) : Colors . white ,
2026-09-22 08:38:19 -04:00
borderRadius: BorderRadius . circular ( 6 ),
2026-09-22 10:16:43 -04:00
border: Border . all (
color: isActive ? AppColors . primary : const Color ( 0xFFCBD5E1 ),
width: isActive ? 1.2 : 1.0 ,
),
2026-09-22 08:38:19 -04:00
),
child: Row (
mainAxisSize: MainAxisSize . min ,
children: [
Icon ( icon , size: 14 , color: AppColors . primary ),
const SizedBox ( width: 4 ),
Text (
label ,
2026-09-22 10:16:43 -04:00
style: TextStyle (
2026-09-22 08:38:19 -04:00
fontSize: 11.5 ,
fontWeight: FontWeight . w600 ,
2026-09-22 10:16:43 -04:00
color: isActive ? AppColors . primary : AppColors . textPrimary ,
2026-09-22 08:38:19 -04:00
),
),
],
),
),
),
);
}
}
2026-09-22 10:16:43 -04:00
class _PosologyChip extends StatelessWidget {
2026-09-22 08:38:19 -04:00
final String label ;
final VoidCallback onTap ;
2026-09-22 10:16:43 -04:00
const _PosologyChip ({
2026-09-22 08:38:19 -04:00
required this . label ,
required this . onTap ,
});
@ override
Widget build ( BuildContext context ) {
2026-09-22 10:16:43 -04:00
return InkWell (
onTap: onTap ,
borderRadius: BorderRadius . circular ( 6 ),
child: Container (
padding: const EdgeInsets . symmetric ( horizontal: 7 , vertical: 3.5 ),
decoration: BoxDecoration (
color: Colors . white ,
borderRadius: BorderRadius . circular ( 6 ),
border: Border . all ( color: const Color ( 0xFFCBD5E1 )),
boxShadow: [
BoxShadow (
color: Colors . black . withValues ( alpha: 0.02 ),
blurRadius: 2 ,
offset: const Offset ( 0 , 1 ),
2026-09-22 08:38:19 -04:00
),
2026-09-22 10:16:43 -04:00
],
),
child: Text (
label ,
style: const TextStyle (
fontSize: 11 ,
fontWeight: FontWeight . w600 ,
color: AppColors . textPrimary ,
2026-09-22 08:38:19 -04:00
),
),
),
);
}
}
2026-09-22 10:16:43 -04:00
class _PosologyCategoryRowWidget extends StatefulWidget {
final String badgeLabel ;
final Color badgeBgColor ;
final Color badgeTextColor ;
final List < String > items ;
final ValueChanged < String > onSelected ;
const _PosologyCategoryRowWidget ({
required this . badgeLabel ,
required this . badgeBgColor ,
required this . badgeTextColor ,
required this . items ,
required this . onSelected ,
});
@ override
State < _PosologyCategoryRowWidget > createState () => _PosologyCategoryRowWidgetState ();
}
class _PosologyCategoryRowWidgetState extends State < _PosologyCategoryRowWidget > {
final ScrollController _scrollController = ScrollController ();
bool _canScrollLeft = false ;
bool _canScrollRight = false ;
@ override
void initState () {
super . initState ();
_scrollController . addListener ( _updateScrollIndicators );
WidgetsBinding . instance . addPostFrameCallback (( _ ) => _updateScrollIndicators ());
}
void _updateScrollIndicators () {
if ( ! mounted || ! _scrollController . hasClients ) return ;
final pos = _scrollController . position ;
final canLeft = pos . pixels > 3 ;
final canRight = pos . pixels < ( pos . maxScrollExtent - 3 );
if ( canLeft != _canScrollLeft || canRight != _canScrollRight ) {
setState (() {
_canScrollLeft = canLeft ;
_canScrollRight = canRight ;
});
}
}
void _scroll ( double delta ) {
if ( ! _scrollController . hasClients ) return ;
_scrollController . animateTo (
( _scrollController . offset + delta ). clamp ( 0.0 , _scrollController . position . maxScrollExtent ),
duration: const Duration ( milliseconds: 220 ),
curve: Curves . easeOutCubic ,
);
}
@ override
void dispose () {
_scrollController . dispose ();
super . dispose ();
}
@ override
Widget build ( BuildContext context ) {
return Row (
crossAxisAlignment: CrossAxisAlignment . center ,
children: [
// Category Badge
Container (
width: 82 ,
padding: const EdgeInsets . symmetric ( horizontal: 5 , vertical: 2.5 ),
decoration: BoxDecoration (
color: widget . badgeBgColor ,
borderRadius: BorderRadius . circular ( 4 ),
border: Border . all ( color: widget . badgeTextColor . withValues ( alpha: 0.25 )),
),
child: Text (
widget . badgeLabel ,
style: TextStyle (
fontSize: 9.5 ,
fontWeight: FontWeight . w700 ,
color: widget . badgeTextColor ,
),
),
),
const SizedBox ( width: 6 ),
// Botón flecha izquierda cuando hay contenido antes
if ( _canScrollLeft ) ...[
InkWell (
onTap: () => _scroll ( - 120 ),
borderRadius: BorderRadius . circular ( 10 ),
child: Container (
padding: const EdgeInsets . all ( 2 ),
decoration: BoxDecoration (
color: Colors . white ,
shape: BoxShape . circle ,
border: Border . all ( color: const Color ( 0xFFCBD5E1 )),
boxShadow: [
BoxShadow (
color: Colors . black . withValues ( alpha: 0.04 ),
blurRadius: 2 ,
),
],
),
child: const Icon ( Icons . chevron_left , size: 14 , color: AppColors . textPrimary ),
),
),
const SizedBox ( width: 4 ),
],
// Área de desplazamiento horizontal
Expanded (
child: ScrollConfiguration (
behavior: const MaterialScrollBehavior (). copyWith (
dragDevices: {
PointerDeviceKind . touch ,
PointerDeviceKind . mouse ,
PointerDeviceKind . trackpad ,
PointerDeviceKind . stylus ,
},
),
child: SingleChildScrollView (
controller: _scrollController ,
scrollDirection: Axis . horizontal ,
physics: const BouncingScrollPhysics (),
child: Row (
children: widget . items . map (( item ) {
return Padding (
padding: const EdgeInsets . only ( right: 5 ),
child: _PosologyChip (
label: item ,
onTap: () => widget . onSelected ( item ),
),
);
}). toList (),
),
),
),
),
// Botón flecha derecha cuando hay contenido después
if ( _canScrollRight ) ...[
const SizedBox ( width: 4 ),
InkWell (
onTap: () => _scroll ( 120 ),
borderRadius: BorderRadius . circular ( 10 ),
child: Container (
padding: const EdgeInsets . all ( 2 ),
decoration: BoxDecoration (
color: Colors . white ,
shape: BoxShape . circle ,
border: Border . all ( color: const Color ( 0xFFCBD5E1 )),
boxShadow: [
BoxShadow (
color: Colors . black . withValues ( alpha: 0.04 ),
blurRadius: 2 ,
),
],
),
child: const Icon ( Icons . chevron_right , size: 14 , color: AppColors . textPrimary ),
),
),
],
],
);
}
}