feat(patient): implement doctor-only patient deletion, alert theme styling, and test auto-cleanup
This commit is contained in:
@@ -104,6 +104,40 @@ class AppTheme {
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
titleTextStyle: const TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
contentTextStyle: const TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: const TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ class _AppointmentsModalViewState extends State<AppointmentsModalView> {
|
||||
widget.onStatusChanged!(patientId, 'atendido');
|
||||
}
|
||||
},
|
||||
onDeleted: (_) {
|
||||
_fetchAppointments(_selectedDate);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,25 +50,64 @@ class _DashboardViewState extends State<DashboardView> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.logout_rounded, color: AppColors.error),
|
||||
SizedBox(width: 10),
|
||||
Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.logout_rounded, color: AppColors.error, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Cerrar Sesión', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
content: const Text('¿Está seguro de que desea salir del sistema Hidalyx Med?'),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, color: AppColors.primary, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'¿Está seguro de que desea salir del sistema Hidalyx Med?',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textPrimary, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancelar'),
|
||||
child: const Text('Cancelar', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
ElevatedButton(
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
icon: const Icon(Icons.logout_rounded, size: 16),
|
||||
label: const Text('Salir del Sistema', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
onPressed: () async {
|
||||
Navigator.of(dialogContext).pop();
|
||||
await AuthService().logout();
|
||||
@@ -76,7 +115,6 @@ class _DashboardViewState extends State<DashboardView> {
|
||||
context.go('/login');
|
||||
}
|
||||
},
|
||||
child: const Text('Salir'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -276,6 +314,7 @@ class _DashboardViewState extends State<DashboardView> {
|
||||
patientId: patient.id,
|
||||
isSecretary: _isSecretary,
|
||||
onSaved: (_) => _store.loadDashboardData(),
|
||||
onDeleted: (_) => _store.loadDashboardData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,4 +61,18 @@ class PatientService {
|
||||
throw Exception('Error al actualizar paciente: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deletePatient(int id) async {
|
||||
try {
|
||||
final response = await apiClient.dio.delete('/items/pacientes/$id');
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
throw Exception('Error en el servidor al eliminar el paciente');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message;
|
||||
throw Exception('Error al eliminar paciente: $serverMessage');
|
||||
} catch (e) {
|
||||
throw Exception('Error al eliminar paciente: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ abstract class _PatientStoreBase with Store {
|
||||
@observable
|
||||
bool isSaving = false;
|
||||
|
||||
@observable
|
||||
bool isDeleting = false;
|
||||
|
||||
@observable
|
||||
String? errorMessage;
|
||||
|
||||
@@ -185,6 +188,7 @@ abstract class _PatientStoreBase with Store {
|
||||
successMessage = null;
|
||||
isLoading = false;
|
||||
isSaving = false;
|
||||
isDeleting = false;
|
||||
|
||||
final now = DateTime.now();
|
||||
fecReg = DateFormat('dd/MM/yyyy').format(now);
|
||||
@@ -391,4 +395,31 @@ abstract class _PatientStoreBase with Store {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<bool> deletePatient() async {
|
||||
if (id == null) {
|
||||
errorMessage = 'No se puede eliminar un paciente que no ha sido guardado.';
|
||||
return false;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try {
|
||||
await service.deletePatient(id!);
|
||||
runInAction(() {
|
||||
isDeleting = false;
|
||||
successMessage = 'Paciente eliminado exitosamente';
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
runInAction(() {
|
||||
errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
isDeleting = false;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,24 @@ mixin _$PatientStore on _PatientStoreBase, Store {
|
||||
});
|
||||
}
|
||||
|
||||
late final _$isDeletingAtom = Atom(
|
||||
name: '_PatientStoreBase.isDeleting',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get isDeleting {
|
||||
_$isDeletingAtom.reportRead();
|
||||
return super.isDeleting;
|
||||
}
|
||||
|
||||
@override
|
||||
set isDeleting(bool value) {
|
||||
_$isDeletingAtom.reportWrite(value, super.isDeleting, () {
|
||||
super.isDeleting = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$errorMessageAtom = Atom(
|
||||
name: '_PatientStoreBase.errorMessage',
|
||||
context: context,
|
||||
@@ -769,6 +787,16 @@ mixin _$PatientStore on _PatientStoreBase, Store {
|
||||
return _$savePatientAsyncAction.run(() => super.savePatient());
|
||||
}
|
||||
|
||||
late final _$deletePatientAsyncAction = AsyncAction(
|
||||
'_PatientStoreBase.deletePatient',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> deletePatient() {
|
||||
return _$deletePatientAsyncAction.run(() => super.deletePatient());
|
||||
}
|
||||
|
||||
late final _$_PatientStoreBaseActionController = ActionController(
|
||||
name: '_PatientStoreBase',
|
||||
context: context,
|
||||
@@ -828,6 +856,7 @@ mixin _$PatientStore on _PatientStoreBase, Store {
|
||||
id: ${id},
|
||||
isLoading: ${isLoading},
|
||||
isSaving: ${isSaving},
|
||||
isDeleting: ${isDeleting},
|
||||
errorMessage: ${errorMessage},
|
||||
successMessage: ${successMessage},
|
||||
fecReg: ${fecReg},
|
||||
|
||||
@@ -18,14 +18,18 @@ class PatientModalView extends StatefulWidget {
|
||||
final PatientModel? initialPatient;
|
||||
final int? patientId;
|
||||
final ValueChanged<PatientModel>? onSaved;
|
||||
final ValueChanged<int>? onDeleted;
|
||||
final bool isSecretary;
|
||||
final PatientStore? store;
|
||||
|
||||
const PatientModalView({
|
||||
super.key,
|
||||
this.initialPatient,
|
||||
this.patientId,
|
||||
this.onSaved,
|
||||
this.onDeleted,
|
||||
this.isSecretary = false,
|
||||
this.store,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -39,13 +43,13 @@ class _PatientModalViewState extends State<PatientModalView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_store = PatientStore();
|
||||
_store = widget.store ?? PatientStore();
|
||||
|
||||
if (widget.initialPatient != null) {
|
||||
_store.populateFromModel(widget.initialPatient!);
|
||||
} else if (widget.patientId != null) {
|
||||
_store.loadPatient(widget.patientId!);
|
||||
} else {
|
||||
} else if (widget.store == null) {
|
||||
_store.initForCreate();
|
||||
}
|
||||
}
|
||||
@@ -83,6 +87,176 @@ class _PatientModalViewState extends State<PatientModalView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDeletePatient() async {
|
||||
final patientId = _store.id;
|
||||
if (patientId == null) return;
|
||||
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
|
||||
final cedula = _store.cedula.trim().isNotEmpty ? _store.cedula.trim() : 'No registrada';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (builderCtx, setDialogState) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(20, 10, 20, 16),
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.delete_forever_rounded, color: AppColors.error, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'¿Eliminar Ficha del Paciente?',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
patientName.isNotEmpty ? patientName : 'Paciente sin nombre',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.primaryDark),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'ID: #$patientId',
|
||||
style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text('Cédula: $cedula', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Esta acción es permanente e irreversible. Se eliminará el expediente completo, antecedentes médicos, consultas, recetas y citas asociadas.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Color(0xFF991B1B),
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _store.isDeleting ? null : () => Navigator.of(dialogCtx).pop(false),
|
||||
child: const Text('Cancelar', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _store.isDeleting
|
||||
? null
|
||||
: () async {
|
||||
setDialogState(() {});
|
||||
final success = await _store.deletePatient();
|
||||
if (dialogCtx.mounted) {
|
||||
Navigator.of(dialogCtx).pop(success);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
icon: _store.isDeleting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.delete_outline_rounded, size: 16),
|
||||
label: Text(
|
||||
_store.isDeleting ? 'Eliminando...' : 'Eliminar Paciente',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (confirmed == true) {
|
||||
if (widget.onDeleted != null) {
|
||||
widget.onDeleted!(patientId);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ficha del paciente $patientName eliminada exitosamente.'),
|
||||
backgroundColor: const Color(0xFF00A65A),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
@@ -192,7 +366,31 @@ class _PatientModalViewState extends State<PatientModalView> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (!widget.isSecretary)
|
||||
if (!widget.isSecretary) ...[
|
||||
if (_store.isEditing) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: _store.isDeleting || _store.isSaving
|
||||
? null
|
||||
: () => _confirmDeletePatient(),
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 16, color: AppColors.error),
|
||||
label: const Text(
|
||||
'Eliminar',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 0),
|
||||
minimumSize: const Size(0, 34),
|
||||
side: BorderSide(color: AppColors.error.withValues(alpha: 0.35)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: AppColors.error.withValues(alpha: 0.04),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ClinicalDocumentsDropdown(
|
||||
onSelect: (docType) {
|
||||
if (docType == ClinicalDocumentType.recetaMedica) {
|
||||
@@ -235,11 +433,12 @@ class _PatientModalViewState extends State<PatientModalView> {
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
bottomBar: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF8FAFC),
|
||||
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -93,6 +93,18 @@ void main() {
|
||||
expect(created.id, isNotNull);
|
||||
expect(created.id! > 0, isTrue);
|
||||
|
||||
// Rutina de limpieza automática para no dejar registros basura en la BD
|
||||
addTearDown(() async {
|
||||
if (created.id != null) {
|
||||
try {
|
||||
await apiClient.dio.delete('/items/pacientes/${created.id}');
|
||||
print('🧹 [DIRECTUS TEST] Paciente de prueba ${created.id} eliminado correctamente tras la verificación.');
|
||||
} catch (e) {
|
||||
print('⚠️ [DIRECTUS TEST] No se pudo eliminar el paciente de prueba: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Verificar obtención directa por ID
|
||||
print('\n[DIRECTUS TEST] 4. Verificando getPatientById(${created.id})...');
|
||||
final fetched = await patientService.getPatientById(created.id!);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hidalyx_drluiscastro/features/patient/data/models/patient_model.dart';
|
||||
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';
|
||||
|
||||
class MockPatientServiceForDelete extends PatientService {
|
||||
int? deletedPatientId;
|
||||
bool shouldFail = false;
|
||||
|
||||
@override
|
||||
Future<void> deletePatient(int id) async {
|
||||
if (shouldFail) {
|
||||
throw Exception('Error simulado al eliminar paciente');
|
||||
}
|
||||
deletedPatientId = id;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Patient Deletion Unit & Store Tests', () {
|
||||
test('PatientStore.deletePatient deletes patient and updates state', () async {
|
||||
final mockService = MockPatientServiceForDelete();
|
||||
final store = PatientStore(patientService: mockService);
|
||||
|
||||
// Attempt to delete with null id
|
||||
final nullResult = await store.deletePatient();
|
||||
expect(nullResult, isFalse);
|
||||
expect(store.errorMessage, contains('No se puede eliminar un paciente que no ha sido guardado'));
|
||||
|
||||
// Populate patient
|
||||
store.populateFromModel(const PatientModel(
|
||||
id: 777,
|
||||
nombre: 'CARLOS',
|
||||
apellido: 'MARTINEZ',
|
||||
));
|
||||
expect(store.id, 777);
|
||||
|
||||
// Execute deletion
|
||||
final success = await store.deletePatient();
|
||||
expect(success, isTrue);
|
||||
expect(mockService.deletedPatientId, 777);
|
||||
expect(store.isDeleting, isFalse);
|
||||
expect(store.successMessage, 'Paciente eliminado exitosamente');
|
||||
expect(store.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('PatientStore.deletePatient handles service errors gracefully', () async {
|
||||
final mockService = MockPatientServiceForDelete()..shouldFail = true;
|
||||
final store = PatientStore(patientService: mockService);
|
||||
|
||||
store.populateFromModel(const PatientModel(
|
||||
id: 888,
|
||||
nombre: 'LUCIA',
|
||||
apellido: 'MENDEZ',
|
||||
));
|
||||
|
||||
final success = await store.deletePatient();
|
||||
expect(success, isFalse);
|
||||
expect(store.isDeleting, isFalse);
|
||||
expect(store.errorMessage, contains('Error simulado al eliminar paciente'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Patient Deletion UI & Role Tests', () {
|
||||
testWidgets('Doctor sees "Eliminar" button when editing an existing patient', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const patient = PatientModel(
|
||||
id: 555,
|
||||
nombre: 'JUAN',
|
||||
apellido: 'PEREZ',
|
||||
cedula: '001-9999999-1',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PatientModalView(
|
||||
initialPatient: patient,
|
||||
isSecretary: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Eliminar'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Doctor does NOT see "Eliminar" button when creating a new patient', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PatientModalView(
|
||||
isSecretary: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Eliminar'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Secretary NEVER sees "Eliminar" button even when editing an existing patient', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const patient = PatientModel(
|
||||
id: 555,
|
||||
nombre: 'JUAN',
|
||||
apellido: 'PEREZ',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PatientModalView(
|
||||
initialPatient: patient,
|
||||
isSecretary: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Eliminar'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Clicking "Eliminar" opens confirmation dialog, Cancel does not delete', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mockService = MockPatientServiceForDelete();
|
||||
final store = PatientStore(patientService: mockService);
|
||||
store.populateFromModel(const PatientModel(
|
||||
id: 123,
|
||||
nombre: 'ROBERTO',
|
||||
apellido: 'GARCIA',
|
||||
cedula: '402-1234567-8',
|
||||
));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PatientModalView(
|
||||
store: store,
|
||||
isSecretary: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Click Eliminar button
|
||||
await tester.tap(find.text('Eliminar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify confirmation dialog
|
||||
expect(find.text('¿Eliminar Ficha del Paciente?'), findsOneWidget);
|
||||
expect(find.text('ROBERTO GARCIA'), findsOneWidget);
|
||||
expect(find.textContaining('#123'), findsOneWidget);
|
||||
expect(find.textContaining('Esta acción es permanente e irreversible'), findsOneWidget);
|
||||
|
||||
// Click Cancelar
|
||||
await tester.tap(find.text('Cancelar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog dismissed, patient NOT deleted
|
||||
expect(find.text('¿Eliminar Ficha del Paciente?'), findsNothing);
|
||||
expect(mockService.deletedPatientId, isNull);
|
||||
});
|
||||
|
||||
testWidgets('Confirming deletion invokes service, calls onDeleted, and closes modal', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mockService = MockPatientServiceForDelete();
|
||||
final store = PatientStore(patientService: mockService);
|
||||
store.populateFromModel(const PatientModel(
|
||||
id: 999,
|
||||
nombre: 'ALBERTO',
|
||||
apellido: 'MOREL',
|
||||
));
|
||||
|
||||
int? deletedIdReceived;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (ctx) => ElevatedButton(
|
||||
onPressed: () => showDialog(
|
||||
context: ctx,
|
||||
builder: (_) => PatientModalView(
|
||||
store: store,
|
||||
isSecretary: false,
|
||||
onDeleted: (id) => deletedIdReceived = id,
|
||||
),
|
||||
),
|
||||
child: const Text('Open Modal'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open modal
|
||||
await tester.tap(find.text('Open Modal'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Click Eliminar
|
||||
await tester.tap(find.text('Eliminar'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Click Eliminar Paciente inside confirmation dialog
|
||||
await tester.tap(find.text('Eliminar Paciente'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mockService.deletedPatientId, 999);
|
||||
expect(deletedIdReceived, 999);
|
||||
expect(find.textContaining('eliminada exitosamente'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -127,9 +127,10 @@ void main() {
|
||||
expect(find.text('Ficha de Recepción'), findsOneWidget);
|
||||
expect(find.text('Recepción, datos personales y programación de citas'), findsOneWidget);
|
||||
|
||||
// Confidential clinical documents and clinical tabs MUST NOT exist
|
||||
// Confidential clinical documents, clinical tabs and delete button MUST NOT exist
|
||||
expect(find.byType(ClinicalDocumentsDropdown), findsNothing);
|
||||
expect(find.byType(PatientClinicalTabs), findsNothing);
|
||||
expect(find.text('Eliminar'), findsNothing);
|
||||
|
||||
// Secretary appointment section MUST exist
|
||||
expect(find.byType(SecretaryAppointmentSection), findsOneWidget);
|
||||
@@ -160,9 +161,10 @@ void main() {
|
||||
// Verify title reflects doctor editing
|
||||
expect(find.text('Editar Paciente'), findsOneWidget);
|
||||
|
||||
// Doctor gets clinical documents dropdown and clinical tabs
|
||||
// Doctor gets clinical documents dropdown, clinical tabs and delete button
|
||||
expect(find.byType(ClinicalDocumentsDropdown), findsOneWidget);
|
||||
expect(find.byType(PatientClinicalTabs), findsOneWidget);
|
||||
expect(find.text('Eliminar'), findsOneWidget);
|
||||
expect(find.byType(SecretaryAppointmentSection), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user