first commit

This commit is contained in:
=
2026-09-21 16:33:51 -04:00
commit a899479dc9
204 changed files with 21132 additions and 0 deletions
@@ -0,0 +1,64 @@
class PatientSearchItemModel {
final int id;
final String nombre;
final String apellido;
final String cedula;
final String telefono;
final String edad;
final String sexo;
final String fecUltcon;
final String seguro;
final String motCons;
const PatientSearchItemModel({
required this.id,
required this.nombre,
required this.apellido,
required this.cedula,
required this.telefono,
required this.edad,
required this.sexo,
required this.fecUltcon,
required this.seguro,
required this.motCons,
});
String get fullName => '$nombre $apellido'.trim();
factory PatientSearchItemModel.fromJson(Map<String, dynamic> json) {
int parsedId = 0;
if (json['id'] is int) {
parsedId = json['id'] as int;
} else if (json['id'] != null) {
parsedId = int.tryParse(json['id'].toString()) ?? 0;
}
return PatientSearchItemModel(
id: parsedId,
nombre: json['nombre']?.toString().trim() ?? '',
apellido: json['apellido']?.toString().trim() ?? '',
cedula: json['cedula']?.toString().trim() ?? '',
telefono: json['telefono']?.toString().trim() ?? '',
edad: json['edad']?.toString().trim() ?? '',
sexo: json['sexo']?.toString().trim() ?? '',
fecUltcon: json['fec_ultcon']?.toString().trim() ?? '',
seguro: json['seguro']?.toString().trim() ?? '',
motCons: json['mot_cons']?.toString().trim() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nombre': nombre,
'apellido': apellido,
'cedula': cedula,
'telefono': telefono,
'edad': edad,
'sexo': sexo,
'fec_ultcon': fecUltcon,
'seguro': seguro,
'mot_cons': motCons,
};
}
}
@@ -0,0 +1,52 @@
import '../../../../core/network/api_client.dart';
import '../models/patient_search_item_model.dart';
class PatientSearchService {
final ApiClient apiClient;
PatientSearchService({ApiClient? client}) : apiClient = client ?? ApiClient();
Future<List<PatientSearchItemModel>> searchPatients(String rawQuery, {int limit = 25}) async {
final cleanQuery = rawQuery.trim();
if (cleanQuery.isEmpty) return [];
try {
final words = cleanQuery.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
final queryParams = <String>[
'limit=$limit',
'sort=-id',
'fields=id,nombre,apellido,cedula,telefono,edad,sexo,fec_ultcon,seguro,mot_cons',
];
if (words.length == 1) {
final w = Uri.encodeQueryComponent(words.first);
queryParams.add('filter[_or][0][nombre][_icontains]=$w');
queryParams.add('filter[_or][1][apellido][_icontains]=$w');
queryParams.add('filter[_or][2][cedula][_icontains]=$w');
queryParams.add('filter[_or][3][telefono][_icontains]=$w');
if (int.tryParse(words.first) != null) {
queryParams.add('filter[_or][4][id][_eq]=${words.first}');
}
} else {
for (var i = 0; i < words.length; i++) {
final w = Uri.encodeQueryComponent(words[i]);
queryParams.add('filter[_and][$i][_or][0][nombre][_icontains]=$w');
queryParams.add('filter[_and][$i][_or][1][apellido][_icontains]=$w');
}
}
final url = '/items/pacientes?${queryParams.join('&')}';
final response = await apiClient.dio.get(url);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as List<dynamic>? ?? [];
return data
.map((item) => PatientSearchItemModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (e) {
throw Exception('Error al consultar pacientes en Directus: $e');
}
}
}
@@ -0,0 +1,71 @@
import 'package:mobx/mobx.dart';
import '../../data/models/patient_search_item_model.dart';
import '../../data/services/patient_search_service.dart';
part 'patient_search_store.g.dart';
// ignore: library_private_types_in_public_api
class PatientSearchStore = _PatientSearchStoreBase with _$PatientSearchStore;
abstract class _PatientSearchStoreBase with Store {
final PatientSearchService service;
_PatientSearchStoreBase({PatientSearchService? searchService})
: service = searchService ?? PatientSearchService();
@observable
String query = '';
@observable
bool isLoading = false;
@observable
bool hasSearched = false;
@observable
String? errorMessage;
@observable
ObservableList<PatientSearchItemModel> results = ObservableList<PatientSearchItemModel>();
@computed
int get resultsCount => results.length;
@action
void setQuery(String value) {
query = value;
}
@action
Future<void> search(String rawQuery) async {
final clean = rawQuery.trim();
if (clean.isEmpty) return;
query = clean;
isLoading = true;
errorMessage = null;
try {
final items = await service.searchPatients(clean);
runInAction(() {
results = ObservableList.of(items);
hasSearched = true;
isLoading = false;
});
} catch (e) {
runInAction(() {
errorMessage = e.toString().replaceAll('Exception: ', '');
hasSearched = true;
isLoading = false;
});
}
}
@action
void clear() {
query = '';
hasSearched = false;
errorMessage = null;
results.clear();
}
}
@@ -0,0 +1,160 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'patient_search_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$PatientSearchStore on _PatientSearchStoreBase, Store {
Computed<int>? _$resultsCountComputed;
@override
int get resultsCount => (_$resultsCountComputed ??= Computed<int>(
() => super.resultsCount,
name: '_PatientSearchStoreBase.resultsCount',
)).value;
late final _$queryAtom = Atom(
name: '_PatientSearchStoreBase.query',
context: context,
);
@override
String get query {
_$queryAtom.reportRead();
return super.query;
}
@override
set query(String value) {
_$queryAtom.reportWrite(value, super.query, () {
super.query = value;
});
}
late final _$isLoadingAtom = Atom(
name: '_PatientSearchStoreBase.isLoading',
context: context,
);
@override
bool get isLoading {
_$isLoadingAtom.reportRead();
return super.isLoading;
}
@override
set isLoading(bool value) {
_$isLoadingAtom.reportWrite(value, super.isLoading, () {
super.isLoading = value;
});
}
late final _$hasSearchedAtom = Atom(
name: '_PatientSearchStoreBase.hasSearched',
context: context,
);
@override
bool get hasSearched {
_$hasSearchedAtom.reportRead();
return super.hasSearched;
}
@override
set hasSearched(bool value) {
_$hasSearchedAtom.reportWrite(value, super.hasSearched, () {
super.hasSearched = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_PatientSearchStoreBase.errorMessage',
context: context,
);
@override
String? get errorMessage {
_$errorMessageAtom.reportRead();
return super.errorMessage;
}
@override
set errorMessage(String? value) {
_$errorMessageAtom.reportWrite(value, super.errorMessage, () {
super.errorMessage = value;
});
}
late final _$resultsAtom = Atom(
name: '_PatientSearchStoreBase.results',
context: context,
);
@override
ObservableList<PatientSearchItemModel> get results {
_$resultsAtom.reportRead();
return super.results;
}
@override
set results(ObservableList<PatientSearchItemModel> value) {
_$resultsAtom.reportWrite(value, super.results, () {
super.results = value;
});
}
late final _$searchAsyncAction = AsyncAction(
'_PatientSearchStoreBase.search',
context: context,
);
@override
Future<void> search(String rawQuery) {
return _$searchAsyncAction.run(() => super.search(rawQuery));
}
late final _$_PatientSearchStoreBaseActionController = ActionController(
name: '_PatientSearchStoreBase',
context: context,
);
@override
void setQuery(String value) {
final _$actionInfo = _$_PatientSearchStoreBaseActionController.startAction(
name: '_PatientSearchStoreBase.setQuery',
);
try {
return super.setQuery(value);
} finally {
_$_PatientSearchStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void clear() {
final _$actionInfo = _$_PatientSearchStoreBaseActionController.startAction(
name: '_PatientSearchStoreBase.clear',
);
try {
return super.clear();
} finally {
_$_PatientSearchStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
query: ${query},
isLoading: ${isLoading},
hasSearched: ${hasSearched},
errorMessage: ${errorMessage},
results: ${results},
resultsCount: ${resultsCount}
''';
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/medical_modal.dart';
import '../../../patient/presentation/views/patient_modal_view.dart';
import '../../data/models/patient_search_item_model.dart';
import '../store/patient_search_store.dart';
import '../widgets/search_empty_state.dart';
import '../widgets/search_input_bar.dart';
import '../widgets/search_results_list.dart';
class PatientSearchModalView extends StatefulWidget {
final ValueChanged<PatientSearchItemModel>? onPatientSelected;
final bool isSecretary;
const PatientSearchModalView({
super.key,
this.onPatientSelected,
this.isSecretary = false,
});
@override
State<PatientSearchModalView> createState() => _PatientSearchModalViewState();
}
class _PatientSearchModalViewState extends State<PatientSearchModalView> {
late final PatientSearchStore _store;
@override
void initState() {
super.initState();
_store = PatientSearchStore();
}
void _onSelectPatient(PatientSearchItemModel patient) {
if (widget.onPatientSelected != null) {
widget.onPatientSelected!(patient);
} else {
Navigator.of(context).pop(patient);
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
patientId: patient.id,
isSecretary: widget.isSecretary,
),
);
}
}
@override
Widget build(BuildContext context) {
return MedicalModalDialog(
title: 'Búsqueda de Pacientes',
titleIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.person_search_rounded,
color: AppColors.primary,
size: 20,
),
),
subtitle: Observer(
builder: (_) {
if (_store.isLoading) {
return const Text(
'Consultando registros...',
style: TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
);
}
if (_store.hasSearched) {
return Text(
'${_store.resultsCount} ${_store.resultsCount == 1 ? 'coincidencia encontrada' : 'coincidencias encontradas'}',
style: const TextStyle(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
);
}
return const Text(
'Localice pacientes por nombre, apellido, cédula o teléfono',
style: TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
);
},
),
bottomBar: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: AppColors.cardBg,
border: Border(
top: BorderSide(color: AppColors.border),
),
),
child: const Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: AppColors.textMuted),
SizedBox(width: 6),
Text(
'Presione Enter para buscar · Esc para cerrar',
style: TextStyle(
fontSize: 11.5,
color: AppColors.textMuted,
fontWeight: FontWeight.w500,
),
),
],
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Observer(
builder: (_) => SearchInputBar(
isLoading: _store.isLoading,
onSearch: (q) => _store.search(q),
onClear: () => _store.clear(),
),
),
),
Expanded(
child: Observer(
builder: (_) {
if (_store.isLoading) {
return const SearchEmptyState(type: SearchStateViewType.loading);
}
if (_store.errorMessage != null) {
return SearchEmptyState(
type: SearchStateViewType.error,
errorMessage: _store.errorMessage,
onRetry: () => _store.search(_store.query),
);
}
if (!_store.hasSearched) {
return const SearchEmptyState(type: SearchStateViewType.initial);
}
if (_store.results.isEmpty) {
return SearchEmptyState(
type: SearchStateViewType.empty,
query: _store.query,
);
}
return SearchResultsList(
patients: _store.results,
onPatientSelected: _onSelectPatient,
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
enum SearchStateViewType {
initial,
loading,
empty,
error,
}
class SearchEmptyState extends StatelessWidget {
final SearchStateViewType type;
final String? query;
final String? errorMessage;
final VoidCallback? onRetry;
const SearchEmptyState({
super.key,
required this.type,
this.query,
this.errorMessage,
this.onRetry,
});
@override
Widget build(BuildContext context) {
switch (type) {
case SearchStateViewType.loading:
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 38,
height: 38,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
const SizedBox(height: 18),
Text(
'Buscando pacientes...',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary.withValues(alpha: 0.85),
),
),
const SizedBox(height: 6),
const Text(
'Consultando base de datos médica',
style: TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
);
case SearchStateViewType.initial:
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_search_rounded,
size: 44,
color: AppColors.primary,
),
),
const SizedBox(height: 16),
const Text(
'Localizador Rápido de Pacientes',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: const Text(
'Escriba nombre, apellido, cédula o teléfono y presione Enter o el botón Buscar.',
style: TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.keyboard_return_rounded, size: 14, color: AppColors.textMuted),
SizedBox(width: 6),
Text(
'Presione Enter para iniciar la búsqueda',
style: TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500),
),
],
),
),
],
),
),
);
case SearchStateViewType.empty:
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(
Icons.search_off_rounded,
size: 40,
color: AppColors.warning,
),
),
const SizedBox(height: 14),
const Text(
'No se encontraron coincidencias',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Text(
query != null && query!.isNotEmpty
? 'No hay registros que coincidan con "$query". Intente con un término más corto o verifique la ortografía.'
: 'No se encontraron resultados para los criterios ingresados.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
],
),
),
);
case SearchStateViewType.error:
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(
Icons.error_outline_rounded,
size: 40,
color: AppColors.error,
),
),
const SizedBox(height: 14),
const Text(
'Error al consultar el servidor',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Text(
errorMessage ?? 'Ocurrió un error inesperado al conectar con el servidor.',
style: const TextStyle(
fontSize: 12,
color: AppColors.error,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
if (onRetry != null) ...[
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Reintentar búsqueda'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
],
),
),
);
}
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
class SearchInputBar extends StatefulWidget {
final ValueChanged<String> onSearch;
final VoidCallback onClear;
final bool isLoading;
const SearchInputBar({
super.key,
required this.onSearch,
required this.onClear,
this.isLoading = false,
});
@override
State<SearchInputBar> createState() => _SearchInputBarState();
}
class _SearchInputBarState extends State<SearchInputBar> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _submit() {
final text = _controller.text.trim();
if (text.isNotEmpty) {
widget.onSearch(text);
}
}
void _clear() {
_controller.clear();
widget.onClear();
_focusNode.requestFocus();
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
child: Row(
children: [
const Icon(
Icons.search_rounded,
color: AppColors.primary,
size: 22,
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
style: const TextStyle(
fontSize: 14,
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
),
decoration: const InputDecoration(
hintText: 'Nombre, apellido, cédula o teléfono...',
hintStyle: TextStyle(
fontSize: 13,
color: AppColors.textMuted,
fontWeight: FontWeight.w400,
),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 12),
),
),
),
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.close_rounded, size: 18, color: AppColors.textMuted),
tooltip: 'Limpiar',
onPressed: _clear,
),
const SizedBox(width: 6),
ElevatedButton(
onPressed: widget.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: widget.isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Buscar',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
SizedBox(width: 6),
Icon(Icons.keyboard_return_rounded, size: 15),
],
),
),
],
),
);
}
}
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/patient_search_item_model.dart';
class SearchResultsList extends StatefulWidget {
final List<PatientSearchItemModel> patients;
final ValueChanged<PatientSearchItemModel>? onPatientSelected;
const SearchResultsList({
super.key,
required this.patients,
this.onPatientSelected,
});
@override
State<SearchResultsList> createState() => _SearchResultsListState();
}
class _SearchResultsListState extends State<SearchResultsList> {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scrollbar(
controller: _scrollController,
thumbVisibility: true,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: widget.patients.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final patient = widget.patients[index];
return _PatientSearchCard(
patient: patient,
onTap: () {
if (widget.onPatientSelected != null) {
widget.onPatientSelected!(patient);
}
},
);
},
),
);
}
}
class _PatientSearchCard extends StatefulWidget {
final PatientSearchItemModel patient;
final VoidCallback onTap;
const _PatientSearchCard({
required this.patient,
required this.onTap,
});
@override
State<_PatientSearchCard> createState() => _PatientSearchCardState();
}
class _PatientSearchCardState extends State<_PatientSearchCard> {
bool _isHovered = false;
String _getInitials(String name, String lastName) {
final first = name.isNotEmpty ? name[0].toUpperCase() : '';
final second = lastName.isNotEmpty ? lastName[0].toUpperCase() : '';
return '$first$second';
}
@override
Widget build(BuildContext context) {
final patient = widget.patient;
final initials = _getInitials(patient.nombre, patient.apellido);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isHovered ? AppColors.primary.withValues(alpha: 0.04) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isHovered ? AppColors.primary.withValues(alpha: 0.4) : AppColors.border,
width: _isHovered ? 1.2 : 1.0,
),
boxShadow: _isHovered
? [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 3),
),
]
: [],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar con iniciales
CircleAvatar(
radius: 20,
backgroundColor: _isHovered
? AppColors.primary
: AppColors.primaryContainer.withValues(alpha: 0.7),
child: Text(
initials.isNotEmpty ? initials : 'P',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: _isHovered ? Colors.white : AppColors.primary,
),
),
),
const SizedBox(width: 12),
// Información Principal
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nombre del paciente
Text(
patient.fullName.isNotEmpty ? patient.fullName : 'Sin Nombre Registrado',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
// Detalles en Wrap
Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (patient.cedula.isNotEmpty)
_InfoBadge(
icon: Icons.badge_outlined,
text: patient.cedula,
color: AppColors.textSecondary,
),
if (patient.telefono.isNotEmpty)
_InfoBadge(
icon: Icons.phone_outlined,
text: patient.telefono,
color: AppColors.textSecondary,
),
if (patient.edad.isNotEmpty)
_InfoBadge(
icon: Icons.cake_outlined,
text: ' años',
color: AppColors.textSecondary,
),
if (patient.sexo.isNotEmpty)
_InfoBadge(
icon: Icons.person_outline_rounded,
text: patient.sexo == 'M'
? 'Masculino'
: (patient.sexo == 'F' ? 'Femenino' : patient.sexo),
color: AppColors.textSecondary,
),
if (patient.seguro.isNotEmpty)
_InfoBadge(
icon: Icons.health_and_safety_outlined,
text: patient.seguro,
color: AppColors.primary,
),
],
),
if (patient.fecUltcon.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.history_rounded, size: 13, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
'Última consulta: ',
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
// Botón o flecha de acción
IconButton(
icon: const Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: AppColors.textMuted,
),
tooltip: 'Ver paciente',
onPressed: widget.onTap,
),
],
),
),
),
);
}
}
class _InfoBadge extends StatelessWidget {
final IconData icon;
final String text;
final Color color;
const _InfoBadge({
required this.icon,
required this.text,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color.withValues(alpha: 0.85)),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
fontSize: 11.5,
color: color,
fontWeight: FontWeight.w500,
),
),
],
);
}
}