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,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,
),
),
],
);
}
}