diff --git a/assets/version.json b/assets/version.json index b112d78..b936bc8 100644 --- a/assets/version.json +++ b/assets/version.json @@ -1 +1 @@ -{"version": "0.1.13"} +{"version": "0.1.14"} diff --git a/lib/features/home/presentation/tool_registry.dart b/lib/features/home/presentation/tool_registry.dart index bf5a86c..86d1f1b 100644 --- a/lib/features/home/presentation/tool_registry.dart +++ b/lib/features/home/presentation/tool_registry.dart @@ -124,6 +124,12 @@ class ToolRegistry { icon: Icons.thermostat, route: '/hipertermia', category: ToolCategory.signosValores), + ToolEntry( + id: 'shock_index', + name: 'Indice de Shock', + icon: Icons.show_chart, + route: '/shock-index', + category: ToolCategory.signosValores), // Oxigenoterapia ToolEntry( id: 'o2_calculator', diff --git a/lib/features/shock_index/domain/shock_index_calculator.dart b/lib/features/shock_index/domain/shock_index_calculator.dart new file mode 100644 index 0000000..53d28e0 --- /dev/null +++ b/lib/features/shock_index/domain/shock_index_calculator.dart @@ -0,0 +1,92 @@ +import 'package:emerkit/shared/domain/entities/severity.dart'; + +class ShockIndexResult { + final double shockIndex; + final double modifiedShockIndex; + final double tam; + final Severity severity; + final Severity msiSeverity; + final double? sipaThreshold; + + const ShockIndexResult({ + required this.shockIndex, + required this.modifiedShockIndex, + required this.tam, + required this.severity, + required this.msiSeverity, + this.sipaThreshold, + }); +} + +class ShockIndexCalculator { + const ShockIndexCalculator(); + + ShockIndexResult calculate({ + required int heartRate, + required int systolicBP, + required int diastolicBP, + bool isPediatric = false, + int? ageYears, + }) { + final si = heartRate / systolicBP; + final tam = diastolicBP + (systolicBP - diastolicBP) / 3; + final msi = heartRate / tam; + + double? sipaThreshold; + Severity severity; + + if (isPediatric && ageYears != null) { + sipaThreshold = _sipaThresholdForAge(ageYears); + severity = _sipaSeverity(si, sipaThreshold); + } else { + severity = _adultSeverity(si); + } + + return ShockIndexResult( + shockIndex: si, + modifiedShockIndex: msi, + tam: tam, + severity: severity, + msiSeverity: _msiSeverity(msi), + sipaThreshold: sipaThreshold, + ); + } + + static Severity _adultSeverity(double si) { + if (si < 0.7) { + return const Severity(label: 'Normal', level: SeverityLevel.mild); + } + if (si < 1.0) { + return const Severity( + label: 'Normal-alto', level: SeverityLevel.moderate); + } + if (si <= 1.3) { + return const Severity(label: 'Elevado', level: SeverityLevel.severe); + } + return const Severity(label: 'Muy elevado', level: SeverityLevel.severe); + } + + static Severity _msiSeverity(double msi) { + if (msi >= 0.7 && msi <= 1.3) { + return const Severity(label: 'Normal', level: SeverityLevel.mild); + } + if (msi <= 1.7) { + return const Severity(label: 'Elevado', level: SeverityLevel.moderate); + } + return const Severity(label: 'Muy elevado', level: SeverityLevel.severe); + } + + static double _sipaThresholdForAge(int age) { + if (age <= 3) return 1.22; + if (age <= 6) return 1.2; + if (age <= 12) return 1.0; + return 0.9; // 13-17 + } + + static Severity _sipaSeverity(double si, double threshold) { + if (si < threshold) { + return const Severity(label: 'Normal', level: SeverityLevel.mild); + } + return const Severity(label: 'SIPA elevado', level: SeverityLevel.severe); + } +} diff --git a/lib/features/shock_index/domain/shock_index_data.dart b/lib/features/shock_index/domain/shock_index_data.dart new file mode 100644 index 0000000..4cd811f --- /dev/null +++ b/lib/features/shock_index/domain/shock_index_data.dart @@ -0,0 +1,65 @@ +import 'package:emerkit/shared/domain/entities/clinical_reference.dart'; + +class ShockIndexData { + ShockIndexData._(); + + static const infoSections = { + 'Que es': + 'El Indice de Shock (SI) es el cociente entre la frecuencia cardiaca ' + '(FC) y la tension arterial sistolica (TAS). Es una herramienta ' + 'rapida para detectar shock e hipovolemia oculta, mas sensible que ' + 'FC o TAS por separado, especialmente en la fase compensatoria.', + 'Formulas': 'Indice de Shock (SI) = FC / TAS\n\n' + 'Indice de Shock Modificado (MSI) = FC / TAM\n' + 'TAM = TAD + 1/3 x (TAS - TAD)\n\n' + 'SIPA (pediatrico) = misma formula con umbrales ajustados por edad.', + 'Interpretacion adultos': 'SI < 0.7: Normal\n' + 'SI 0.7 - 0.99: Normal-alto (vigilar)\n' + 'SI 1.0 - 1.3: Elevado - hipovolemia o shock descompensado\n' + 'SI > 1.3: Muy elevado - shock severo, resucitacion inmediata\n\n' + 'MSI 0.7 - 1.3: Rango normal\n' + 'MSI > 1.3: Anormal, mayor riesgo de mortalidad', + 'SIPA pediatrico': '1-3 anos: anormal si >= 1.22\n' + '4-6 anos: anormal si >= 1.20\n' + '7-12 anos: anormal si >= 1.00\n' + '13-17 anos: anormal si >= 0.90', + 'Aplicaciones clinicas': + 'Triaje en trauma: predice necesidad de transfusion masiva e ingreso UCI.\n' + 'Deteccion de hemorragia oculta en fase compensatoria.\n' + 'Screening de sepsis (MSI >= 1.0).\n' + 'Emergencias obstetricas: hemorragia postparto.\n' + 'Triaje en urgencias: asignacion de acuidad y recursos.', + 'Limitaciones': 'No usar como criterio diagnostico unico.\n' + 'Confundido por: betabloqueantes, marcapasos, HTA basal, ' + 'embarazo, dolor, ansiedad.\n' + 'Umbrales pediatricos especificos por edad.\n' + 'No validado en pacientes con arritmias o medicacion cronotropica.', + }; + + static const references = [ + ClinicalReference( + 'Allgower M, Burri C. Schockindex. ' + 'Dtsch Med Wochenschr. 1967;92(43):1947-1950.', + ), + ClinicalReference( + 'Koch E, et al. Shock index in the emergency department: ' + 'utility and limitations. Open Access Emerg Med. 2019;11:179-199.', + ), + ClinicalReference( + 'Liu YC, et al. Modified shock index and mortality rate of ' + 'emergency patients. World J Emerg Med. 2012;3(2):114-117.', + ), + ClinicalReference( + 'Acker SN, et al. Shock index, pediatric age-adjusted (SIPA). ' + 'J Pediatr Surg. 2015;50(2):331-334.', + ), + ClinicalReference( + 'Schroll R, et al. Shock index as predictor of massive ' + 'transfusion and mortality. Crit Care. 2023;27(1):61.', + ), + ClinicalReference( + 'Mutschler M, et al. The Shock Index revisited — a fast guide ' + 'to transfusion requirement? Crit Care. 2013;17(4):R172.', + ), + ]; +} diff --git a/lib/features/shock_index/presentation/shock_index_screen.dart b/lib/features/shock_index/presentation/shock_index_screen.dart new file mode 100644 index 0000000..a4cd092 --- /dev/null +++ b/lib/features/shock_index/presentation/shock_index_screen.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:emerkit/shared/domain/entities/severity.dart'; +import 'package:emerkit/shared/presentation/theme/app_colors.dart'; +import 'package:emerkit/shared/presentation/widgets/tool_screen_base.dart'; +import 'package:emerkit/shared/presentation/widgets/result_banner.dart'; +import 'package:emerkit/shared/presentation/widgets/section_header.dart'; +import 'package:emerkit/shared/presentation/widgets/tool_info_panel.dart'; +import '../domain/shock_index_calculator.dart'; +import '../domain/shock_index_data.dart'; + +class ShockIndexScreen extends StatefulWidget { + const ShockIndexScreen({super.key}); + + @override + State createState() => _ShockIndexScreenState(); +} + +class _ShockIndexScreenState extends State { + static const _calculator = ShockIndexCalculator(); + static const _color = AppColors.signosValores; + + double _heartRate = 80; + double _systolicBP = 120; + double _diastolicBP = 80; + bool _isPediatric = false; + double _age = 8; + + ShockIndexResult get _result => _calculator.calculate( + heartRate: _heartRate.round(), + systolicBP: _systolicBP.round(), + diastolicBP: _diastolicBP.round(), + isPediatric: _isPediatric, + ageYears: _isPediatric ? _age.round() : null, + ); + + void _reset() => setState(() { + _heartRate = 80; + _systolicBP = 120; + _diastolicBP = 80; + _isPediatric = false; + _age = 8; + }); + + Color _colorForSeverity(SeverityLevel level) { + switch (level) { + case SeverityLevel.mild: + return AppColors.severityMild; + case SeverityLevel.moderate: + return AppColors.severityModerate; + case SeverityLevel.severe: + return AppColors.severitySevere; + } + } + + @override + Widget build(BuildContext context) { + final r = _result; + final color = _colorForSeverity(r.severity.level); + + return ToolScreenBase( + title: 'Indice de Shock', + onReset: _reset, + resultWidget: ResultBanner( + value: r.shockIndex.toStringAsFixed(2), + label: r.severity.label, + subtitle: _isPediatric && r.sipaThreshold != null + ? 'SIPA - Umbral: ${r.sipaThreshold!.toStringAsFixed(2)}' + : 'FC ${_heartRate.round()} / TAS ${_systolicBP.round()}', + color: color, + severityLevel: r.severity.level, + ), + toolBody: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSlider( + 'Frecuencia Cardiaca', + '${_heartRate.round()} lpm', + _heartRate, + 30, + 220, + 1, + (v) => setState(() => _heartRate = v), + ), + const SizedBox(height: 16), + _buildSlider( + 'Presion Arterial Sistolica', + '${_systolicBP.round()} mmHg', + _systolicBP, + 50, + 250, + 1, + (v) => setState(() => _systolicBP = v), + ), + const SizedBox(height: 16), + _buildSlider( + 'Presion Arterial Diastolica', + '${_diastolicBP.round()} mmHg', + _diastolicBP, + 20, + 150, + 1, + (v) => setState(() => _diastolicBP = v), + ), + const SizedBox(height: 16), + SwitchListTile( + title: Text('Paciente pediatrico', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold)), + value: _isPediatric, + activeTrackColor: _color, + thumbColor: const WidgetStatePropertyAll(_color), + onChanged: (v) => setState(() => _isPediatric = v), + ), + if (_isPediatric) ...[ + const SizedBox(height: 8), + _buildSlider( + 'Edad', + '${_age.round()} anos', + _age, + 1, + 17, + 1, + (v) => setState(() => _age = v), + ), + ], + const SizedBox(height: 24), + const SectionHeader( + title: 'Resultados detallados', + color: _color, + ), + const SizedBox(height: 8), + _buildResultRow( + 'Indice de Shock (SI)', + r.shockIndex.toStringAsFixed(2), + r.severity, + ), + _buildResultRow( + 'Indice Modificado (MSI)', + r.modifiedShockIndex.toStringAsFixed(2), + r.msiSeverity, + ), + _buildResultRow( + 'Tension Arterial Media', + '${r.tam.round()} mmHg', + null, + ), + if (_isPediatric && r.sipaThreshold != null) + _buildResultRow( + 'Umbral SIPA (${_age.round()} anos)', + '>= ${r.sipaThreshold!.toStringAsFixed(2)}', + null, + ), + ], + ), + infoBody: const ToolInfoPanel( + sections: ShockIndexData.infoSections, + references: ShockIndexData.references, + ), + ); + } + + Widget _buildSlider( + String label, + String valueText, + double value, + double min, + double max, + double step, + ValueChanged onChanged, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold)), + Text(valueText, + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w600, color: _color)), + ], + ), + Slider( + value: value, + min: min, + max: max, + divisions: ((max - min) / step).round(), + activeColor: _color, + onChanged: onChanged, + ), + ], + ); + } + + Widget _buildResultRow(String label, String value, Severity? severity) { + final color = severity != null ? _colorForSeverity(severity.level) : _color; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyLarge), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + if (severity != null) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + severity.label, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/presentation/router/app_router.dart b/lib/shared/presentation/router/app_router.dart index edb5854..1a97576 100644 --- a/lib/shared/presentation/router/app_router.dart +++ b/lib/shared/presentation/router/app_router.dart @@ -31,6 +31,7 @@ import 'package:emerkit/features/glosario/presentation/glosario_screen.dart'; import 'package:emerkit/features/respiratory_rate/presentation/respiratory_rate_screen.dart'; import 'package:emerkit/features/gps_converter/presentation/gps_converter_screen.dart'; import 'package:emerkit/features/wallace/presentation/wallace_screen.dart'; +import 'package:emerkit/features/shock_index/presentation/shock_index_screen.dart'; final appRouter = GoRouter( initialLocation: '/', @@ -76,5 +77,6 @@ final appRouter = GoRouter( GoRoute( path: '/gps-converter', builder: (_, __) => const GpsConverterScreen()), GoRoute(path: '/wallace', builder: (_, __) => const WallaceScreen()), + GoRoute(path: '/shock-index', builder: (_, __) => const ShockIndexScreen()), ], ); diff --git a/pubspec.yaml b/pubspec.yaml index 8624442..9e35872 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: emerkit description: EmerKit - Herramientas clinicas para profesionales de emergencias publish_to: 'none' -version: 0.1.13+113 +version: 0.1.14+114 environment: sdk: ^3.5.0 diff --git a/test/domain/shock_index_calculator_test.dart b/test/domain/shock_index_calculator_test.dart new file mode 100644 index 0000000..acc76c3 --- /dev/null +++ b/test/domain/shock_index_calculator_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:emerkit/shared/domain/entities/severity.dart'; +import 'package:emerkit/features/shock_index/domain/shock_index_calculator.dart'; + +void main() { + const calculator = ShockIndexCalculator(); + + group('Indice de Shock normal (SI < 0.7)', () { + test('FC 60 / TAS 120 = SI 0.50, Normal', () { + final r = + calculator.calculate(heartRate: 60, systolicBP: 120, diastolicBP: 80); + expect(r.shockIndex, closeTo(0.50, 0.01)); + expect(r.severity.label, 'Normal'); + expect(r.severity.level, SeverityLevel.mild); + }); + + test('FC 70 / TAS 130 = SI 0.54, Normal', () { + final r = + calculator.calculate(heartRate: 70, systolicBP: 130, diastolicBP: 85); + expect(r.shockIndex, closeTo(0.54, 0.01)); + expect(r.severity.level, SeverityLevel.mild); + }); + }); + + group('Indice de Shock normal-alto (SI 0.7-0.99)', () { + test('FC 80 / TAS 100 = SI 0.80, Normal-alto', () { + final r = + calculator.calculate(heartRate: 80, systolicBP: 100, diastolicBP: 70); + expect(r.shockIndex, closeTo(0.80, 0.01)); + expect(r.severity.label, 'Normal-alto'); + expect(r.severity.level, SeverityLevel.moderate); + }); + }); + + group('Indice de Shock elevado (SI >= 1.0)', () { + test('FC 120 / TAS 90 = SI 1.33, Muy elevado', () { + final r = + calculator.calculate(heartRate: 120, systolicBP: 90, diastolicBP: 60); + expect(r.shockIndex, closeTo(1.33, 0.01)); + expect(r.severity.label, 'Muy elevado'); + expect(r.severity.level, SeverityLevel.severe); + }); + + test('FC 100 / TAS 90 = SI 1.11, Elevado', () { + final r = + calculator.calculate(heartRate: 100, systolicBP: 90, diastolicBP: 60); + expect(r.shockIndex, closeTo(1.11, 0.01)); + expect(r.severity.label, 'Elevado'); + expect(r.severity.level, SeverityLevel.severe); + }); + }); + + group('Tension Arterial Media (TAM)', () { + test('TAS 120, TAD 80 -> TAM = 93.3', () { + final r = + calculator.calculate(heartRate: 80, systolicBP: 120, diastolicBP: 80); + expect(r.tam, closeTo(93.3, 0.1)); + }); + + test('TAS 90, TAD 60 -> TAM = 70.0', () { + final r = + calculator.calculate(heartRate: 100, systolicBP: 90, diastolicBP: 60); + expect(r.tam, closeTo(70.0, 0.1)); + }); + }); + + group('Indice de Shock Modificado (MSI)', () { + test('FC 80, TAM 93.3 -> MSI = 0.86, Normal', () { + final r = + calculator.calculate(heartRate: 80, systolicBP: 120, diastolicBP: 80); + expect(r.modifiedShockIndex, closeTo(0.86, 0.01)); + expect(r.msiSeverity.label, 'Normal'); + expect(r.msiSeverity.level, SeverityLevel.mild); + }); + + test('FC 140, TAM 70 -> MSI = 2.0, Muy elevado', () { + final r = + calculator.calculate(heartRate: 140, systolicBP: 90, diastolicBP: 60); + expect(r.modifiedShockIndex, closeTo(2.0, 0.01)); + expect(r.msiSeverity.label, 'Muy elevado'); + expect(r.msiSeverity.level, SeverityLevel.severe); + }); + }); + + group('SIPA pediatrico', () { + test('Nino 2 anos, SI 1.30 >= umbral 1.22 -> SIPA elevado', () { + final r = calculator.calculate( + heartRate: 130, + systolicBP: 100, + diastolicBP: 60, + isPediatric: true, + ageYears: 2, + ); + expect(r.shockIndex, closeTo(1.30, 0.01)); + expect(r.sipaThreshold, 1.22); + expect(r.severity.label, 'SIPA elevado'); + expect(r.severity.level, SeverityLevel.severe); + }); + + test('Nino 5 anos, SI 1.0 < umbral 1.20 -> Normal', () { + final r = calculator.calculate( + heartRate: 100, + systolicBP: 100, + diastolicBP: 65, + isPediatric: true, + ageYears: 5, + ); + expect(r.shockIndex, closeTo(1.0, 0.01)); + expect(r.sipaThreshold, 1.20); + expect(r.severity.label, 'Normal'); + expect(r.severity.level, SeverityLevel.mild); + }); + + test('Nino 10 anos, SI 1.10 >= umbral 1.0 -> SIPA elevado', () { + final r = calculator.calculate( + heartRate: 110, + systolicBP: 100, + diastolicBP: 65, + isPediatric: true, + ageYears: 10, + ); + expect(r.shockIndex, closeTo(1.10, 0.01)); + expect(r.sipaThreshold, 1.0); + expect(r.severity.label, 'SIPA elevado'); + expect(r.severity.level, SeverityLevel.severe); + }); + + test('Adolescente 15 anos, SI 0.80 < umbral 0.90 -> Normal', () { + final r = calculator.calculate( + heartRate: 80, + systolicBP: 100, + diastolicBP: 65, + isPediatric: true, + ageYears: 15, + ); + expect(r.shockIndex, closeTo(0.80, 0.01)); + expect(r.sipaThreshold, 0.9); + expect(r.severity.label, 'Normal'); + expect(r.severity.level, SeverityLevel.mild); + }); + }); +} diff --git a/version.json b/version.json index b112d78..b936bc8 100644 --- a/version.json +++ b/version.json @@ -1 +1 @@ -{"version": "0.1.13"} +{"version": "0.1.14"}