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
View File
@@ -0,0 +1,103 @@
class AuthSessionModel {
final String accessToken;
final String? refreshToken;
final int? expires;
final String userId;
final String firstName;
final String lastName;
final String email;
final String? roleName;
const AuthSessionModel({
required this.accessToken,
this.refreshToken,
this.expires,
required this.userId,
required this.firstName,
required this.lastName,
required this.email,
this.roleName,
});
String get fullName {
final name = '$firstName $lastName'.trim();
return name.isNotEmpty ? name : 'Dr. Luis A. Castro Sosa';
}
/// Indica si el usuario actual tiene rol de Secretaria, Asistente o Recepción
bool get isSecretary {
final normalized = (roleName ?? '').trim().toLowerCase();
return normalized.contains('secretari') ||
normalized.contains('asistent') ||
normalized.contains('recepc');
}
/// Indica si el usuario actual es Doctor o Administrador
bool get isDoctorOrAdmin => !isSecretary;
AuthSessionModel copyWith({
String? accessToken,
String? refreshToken,
int? expires,
String? userId,
String? firstName,
String? lastName,
String? email,
String? roleName,
}) {
return AuthSessionModel(
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expires: expires ?? this.expires,
userId: userId ?? this.userId,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
email: email ?? this.email,
roleName: roleName ?? this.roleName,
);
}
factory AuthSessionModel.fromLoginAndUser({
required Map<String, dynamic> loginData,
required Map<String, dynamic> userData,
}) {
return AuthSessionModel(
accessToken: loginData['access_token'] as String? ?? '',
refreshToken: loginData['refresh_token'] as String?,
expires: loginData['expires'] as int?,
userId: userData['id'] as String? ?? '',
firstName: userData['first_name'] as String? ?? '',
lastName: userData['last_name'] as String? ?? '',
email: userData['email'] as String? ?? '',
roleName: (userData['role'] is Map)
? userData['role']['name'] as String?
: userData['role']?.toString(),
);
}
factory AuthSessionModel.fromJson(Map<String, dynamic> json) {
return AuthSessionModel(
accessToken: json['access_token'] as String? ?? '',
refreshToken: json['refresh_token'] as String?,
expires: json['expires'] as int?,
userId: json['user_id'] as String? ?? '',
firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
email: json['email'] as String? ?? '',
roleName: json['role_name'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires': expires,
'user_id': userId,
'first_name': firstName,
'last_name': lastName,
'email': email,
'role_name': roleName,
};
}
}
@@ -0,0 +1,239 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/network/api_client.dart';
import '../models/auth_session_model.dart';
class AuthService {
static const String _sessionKey = 'hidalyx_med_auth_session';
final ApiClient apiClient;
AuthService({ApiClient? client}) : apiClient = client ?? ApiClient() {
apiClient.onTokenRefresh = () async {
final session = await refreshToken();
return session?.accessToken;
};
}
Future<AuthSessionModel?> login({
required String email,
required String password,
}) async {
try {
// 0. Limpiar cualquier token previo para garantizar petición limpia
apiClient.clearAuthToken();
// 1. Autenticación en Directus
final loginResponse = await apiClient.dio.post(
'/auth/login',
data: {
'email': email.trim(),
'password': password,
},
);
if (loginResponse.statusCode == 200 && loginResponse.data != null) {
final loginData = loginResponse.data['data'] as Map<String, dynamic>;
final token = loginData['access_token'] as String;
// 2. Establecer token para futuras peticiones
apiClient.setAuthToken(token);
// 3. Consultar datos reales del usuario y su rol en Directus de forma segura
Map<String, dynamic> userData = {
'id': '',
'first_name': email.contains('castro') ? 'Luis A.' : 'Usuario',
'last_name': email.contains('castro') ? 'Castro Sosa' : '',
'email': email,
};
try {
final userResponse = await apiClient.dio.get(
'/users/me?fields=*.*,role.*',
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (userResponse.data != null && userResponse.data['data'] != null) {
userData = userResponse.data['data'] as Map<String, dynamic>;
}
} catch (_) {
try {
final basicRes = await apiClient.dio.get(
'/users/me',
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (basicRes.data != null && basicRes.data['data'] != null) {
userData = basicRes.data['data'] as Map<String, dynamic>;
}
} catch (_) {}
}
// 4. Crear modelo de sesión
final session = AuthSessionModel.fromLoginAndUser(
loginData: loginData,
userData: userData,
);
// 5. Guardar en SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_sessionKey, jsonEncode(session.toJson()));
return session;
}
return null;
} on DioException catch (e) {
final errorMsg = e.response?.data?['errors']?[0]?['message'] ?? 'Credenciales incorrectas';
throw Exception(errorMsg);
} catch (e) {
throw Exception('Error al iniciar sesión: $e');
}
}
/// Refresca inmediatamente el token de acceso utilizando el endpoint POST /auth/refresh de Directus
Future<AuthSessionModel?> refreshToken() async {
try {
final prefs = await SharedPreferences.getInstance();
final sessionJson = prefs.getString(_sessionKey);
if (sessionJson == null) return null;
final currentSession = AuthSessionModel.fromJson(
jsonDecode(sessionJson) as Map<String, dynamic>,
);
final rToken = currentSession.refreshToken;
if (rToken == null || rToken.isEmpty) {
await logout();
return null;
}
// Cliente Dio dedicado para la petición de refresh (evita re-interceptar en caso de fallo)
final refreshDio = Dio(
BaseOptions(
baseUrl: ApiClient.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {'Content-Type': 'application/json'},
),
);
final refreshResponse = await refreshDio.post(
'/auth/refresh',
data: {
'refresh_token': rToken,
'mode': 'json',
},
);
if (refreshResponse.statusCode == 200 && refreshResponse.data != null) {
final data = refreshResponse.data['data'] as Map<String, dynamic>;
final newAccessToken = data['access_token'] as String;
final newRefreshToken = (data['refresh_token'] as String?) ?? rToken;
final expires = data['expires'] as int?;
final updatedSession = currentSession.copyWith(
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expires: expires,
);
await prefs.setString(_sessionKey, jsonEncode(updatedSession.toJson()));
apiClient.setAuthToken(newAccessToken);
return updatedSession;
}
await logout();
return null;
} catch (_) {
await logout();
return null;
}
}
Future<AuthSessionModel?> loadSavedSession() async {
try {
final prefs = await SharedPreferences.getInstance();
final sessionJson = prefs.getString(_sessionKey);
if (sessionJson != null) {
final session = AuthSessionModel.fromJson(
jsonDecode(sessionJson) as Map<String, dynamic>,
);
if (session.accessToken.isEmpty) {
return null;
}
// Verificamos de inmediato si el token actual sigue activo o si ya expiró
final verifyDio = Dio(
BaseOptions(
baseUrl: ApiClient.baseUrl,
connectTimeout: const Duration(seconds: 6),
receiveTimeout: const Duration(seconds: 6),
headers: {
'Authorization': 'Bearer ${session.accessToken}',
},
),
);
try {
final meRes = await verifyDio.get('/users/me?fields=id');
if (meRes.statusCode == 200) {
apiClient.setAuthToken(session.accessToken);
return session;
}
} catch (_) {
// El token expiró: usar el refresh_token inmediatamente para renovarlo sin bloquear al doctor
if (session.refreshToken != null && session.refreshToken!.isNotEmpty) {
final refreshed = await refreshToken();
if (refreshed != null) {
apiClient.setAuthToken(refreshed.accessToken);
return refreshed;
}
}
await logout();
return null;
}
apiClient.setAuthToken(session.accessToken);
return session;
}
return null;
} catch (_) {
return null;
}
}
Future<void> logout() async {
try {
final prefs = await SharedPreferences.getInstance();
final sessionJson = prefs.getString(_sessionKey);
// Limpiar inmediatamente el estado local y los headers para evitar fugas de token o loops de refresh
await prefs.remove(_sessionKey);
apiClient.clearAuthToken();
if (sessionJson != null) {
final session = AuthSessionModel.fromJson(
jsonDecode(sessionJson) as Map<String, dynamic>,
);
if (session.refreshToken != null && session.refreshToken!.isNotEmpty) {
try {
final logoutDio = Dio(
BaseOptions(
baseUrl: ApiClient.baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
headers: {'Content-Type': 'application/json'},
),
);
await logoutDio.post(
'/auth/logout',
data: {'refresh_token': session.refreshToken},
);
} catch (_) {}
}
}
} catch (_) {
apiClient.clearAuthToken();
}
}
}
@@ -0,0 +1,100 @@
import 'package:mobx/mobx.dart';
import '../../data/models/auth_session_model.dart';
import '../../data/services/auth_service.dart';
part 'auth_store.g.dart';
// ignore: library_private_types_in_public_api
class AuthStore = _AuthStoreBase with _$AuthStore;
abstract class _AuthStoreBase with Store {
final AuthService authService;
_AuthStoreBase({AuthService? service})
: authService = service ?? AuthService();
@observable
bool isLoading = false;
@observable
bool isPasswordVisible = false;
@observable
AuthSessionModel? currentSession;
@observable
String? errorMessage;
@computed
bool get isAuthenticated => currentSession != null;
@action
void togglePasswordVisibility() {
isPasswordVisible = !isPasswordVisible;
}
@action
Future<bool> login({required String email, required String password}) async {
isLoading = true;
errorMessage = null;
currentSession = null;
try {
final session = await authService.login(email: email, password: password);
if (session != null) {
currentSession = session;
return true;
}
errorMessage = 'No se pudo iniciar sesión. Verifique sus credenciales.';
return false;
} catch (e) {
errorMessage = e.toString().replaceAll('Exception: ', '');
return false;
} finally {
isLoading = false;
}
}
@action
Future<void> checkInitialSession() async {
isLoading = true;
try {
final session = await authService.loadSavedSession();
currentSession = session;
} catch (_) {
currentSession = null;
} finally {
isLoading = false;
}
}
@action
Future<bool> refreshToken() async {
isLoading = true;
try {
final session = await authService.refreshToken();
if (session != null) {
currentSession = session;
return true;
}
currentSession = null;
return false;
} catch (_) {
currentSession = null;
return false;
} finally {
isLoading = false;
}
}
@action
Future<void> logout() async {
isLoading = true;
errorMessage = null;
try {
await authService.logout();
currentSession = null;
} finally {
isLoading = false;
}
}
}
@@ -0,0 +1,163 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_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 _$AuthStore on _AuthStoreBase, Store {
Computed<bool>? _$isAuthenticatedComputed;
@override
bool get isAuthenticated => (_$isAuthenticatedComputed ??= Computed<bool>(
() => super.isAuthenticated,
name: '_AuthStoreBase.isAuthenticated',
)).value;
late final _$isLoadingAtom = Atom(
name: '_AuthStoreBase.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 _$isPasswordVisibleAtom = Atom(
name: '_AuthStoreBase.isPasswordVisible',
context: context,
);
@override
bool get isPasswordVisible {
_$isPasswordVisibleAtom.reportRead();
return super.isPasswordVisible;
}
@override
set isPasswordVisible(bool value) {
_$isPasswordVisibleAtom.reportWrite(value, super.isPasswordVisible, () {
super.isPasswordVisible = value;
});
}
late final _$currentSessionAtom = Atom(
name: '_AuthStoreBase.currentSession',
context: context,
);
@override
AuthSessionModel? get currentSession {
_$currentSessionAtom.reportRead();
return super.currentSession;
}
@override
set currentSession(AuthSessionModel? value) {
_$currentSessionAtom.reportWrite(value, super.currentSession, () {
super.currentSession = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_AuthStoreBase.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 _$loginAsyncAction = AsyncAction(
'_AuthStoreBase.login',
context: context,
);
@override
Future<bool> login({required String email, required String password}) {
return _$loginAsyncAction.run(
() => super.login(email: email, password: password),
);
}
late final _$checkInitialSessionAsyncAction = AsyncAction(
'_AuthStoreBase.checkInitialSession',
context: context,
);
@override
Future<void> checkInitialSession() {
return _$checkInitialSessionAsyncAction.run(
() => super.checkInitialSession(),
);
}
late final _$refreshTokenAsyncAction = AsyncAction(
'_AuthStoreBase.refreshToken',
context: context,
);
@override
Future<bool> refreshToken() {
return _$refreshTokenAsyncAction.run(() => super.refreshToken());
}
late final _$logoutAsyncAction = AsyncAction(
'_AuthStoreBase.logout',
context: context,
);
@override
Future<void> logout() {
return _$logoutAsyncAction.run(() => super.logout());
}
late final _$_AuthStoreBaseActionController = ActionController(
name: '_AuthStoreBase',
context: context,
);
@override
void togglePasswordVisibility() {
final _$actionInfo = _$_AuthStoreBaseActionController.startAction(
name: '_AuthStoreBase.togglePasswordVisibility',
);
try {
return super.togglePasswordVisibility();
} finally {
_$_AuthStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
isLoading: ${isLoading},
isPasswordVisible: ${isPasswordVisible},
currentSession: ${currentSession},
errorMessage: ${errorMessage},
isAuthenticated: ${isAuthenticated}
''';
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../store/auth_store.dart';
import '../widgets/login_form_widget.dart';
import '../widgets/login_header_widget.dart';
class LoginView extends StatefulWidget {
final AuthStore? store;
const LoginView({super.key, this.store});
@override
State<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
late final AuthStore _authStore;
@override
void initState() {
super.initState();
_authStore = widget.store ?? AuthStore();
}
void _onSuccess() {
context.go('/');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.04),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const LoginHeaderWidget(),
const SizedBox(height: 28),
LoginFormWidget(
store: _authStore,
onLoginSuccess: _onSuccess,
),
const SizedBox(height: 24),
const Text(
'Sistema protegido · Exclusivo Consultorio Dr. Luis Castro',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import '../../../../core/theme/app_colors.dart';
import '../store/auth_store.dart';
class LoginFormWidget extends StatefulWidget {
final AuthStore store;
final VoidCallback onLoginSuccess;
const LoginFormWidget({
super.key,
required this.store,
required this.onLoginSuccess,
});
@override
State<LoginFormWidget> createState() => _LoginFormWidgetState();
}
class _LoginFormWidgetState extends State<LoginFormWidget> {
final _formKey = GlobalKey<FormBuilderState>();
Future<void> _submit() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
final email = values['email'] as String;
final password = values['password'] as String;
final success = await widget.store.login(
email: email,
password: password,
);
if (success && mounted) {
widget.onLoginSuccess();
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
return FormBuilder(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mensaje de Error si existe
if (widget.store.errorMessage != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.error.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.store.errorMessage!,
style: const TextStyle(
color: AppColors.error,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 16),
],
// Campo Email
const Text(
'Correo Electrónico',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
FormBuilderTextField(
name: 'email',
initialValue: '[email protected]',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
hintText: '[email protected]',
prefixIcon: Icon(Icons.mail_outline_rounded, color: AppColors.textMuted, size: 20),
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'El correo electrónico es requerido'),
FormBuilderValidators.email(errorText: 'Ingrese un correo electrónico válido'),
]),
),
const SizedBox(height: 16),
// Campo Contraseña
const Text(
'Contraseña',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
FormBuilderTextField(
name: 'password',
initialValue: 'LUIS',
obscureText: !widget.store.isPasswordVisible,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
hintText: 'Ingrese su contraseña',
prefixIcon: const Icon(Icons.lock_outline_rounded, color: AppColors.textMuted, size: 20),
suffixIcon: IconButton(
icon: Icon(
widget.store.isPasswordVisible
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: AppColors.textMuted,
size: 20,
),
onPressed: widget.store.togglePasswordVisibility,
),
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'La contraseña es requerida'),
]),
),
const SizedBox(height: 24),
// Botón de Inicio de Sesión
ElevatedButton(
onPressed: widget.store.isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: widget.store.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.login_rounded, size: 18),
SizedBox(width: 8),
Text(
'Ingresar al Consultorio',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../core/theme/app_colors.dart';
class LoginHeaderWidget extends StatelessWidget {
const LoginHeaderWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo de Hidalyx
SvgPicture.asset(
'assets/images/hidalyx_logo_blue.svg',
height: 48,
fit: BoxFit.contain,
),
const SizedBox(height: 18),
const Text(
'Hidalyx Med',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6),
const Text(
'Portal de Gestión Clínica · Dr. Luis A. Castro Sosa',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
fontWeight: FontWeight.w400,
),
),
],
);
}
}
@@ -0,0 +1,53 @@
import '../../../../core/network/api_client.dart';
import '../../../dashboard/data/models/doctor_info_model.dart';
class ConfigService {
final ApiClient apiClient;
ConfigService({ApiClient? client}) : apiClient = client ?? ApiClient();
/// Obtiene la configuración del consultorio desde Directus.
Future<DoctorInfoModel> getConfig() async {
try {
final response = await apiClient.dio.get('/items/configuracion');
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data);
} else if (data is List && data.isNotEmpty && data.first is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data.first as Map<String, dynamic>);
}
}
return const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
} catch (e) {
throw Exception('Error al cargar la configuración del consultorio: $e');
}
}
/// Actualiza la configuración del consultorio en Directus mediante PATCH.
Future<DoctorInfoModel> updateConfig(DoctorInfoModel model) async {
try {
final response = await apiClient.dio.patch(
'/items/configuracion',
data: model.toJson(includeId: false),
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data);
} else if (data is List && data.isNotEmpty && data.first is Map<String, dynamic>) {
return DoctorInfoModel.fromJson(data.first as Map<String, dynamic>);
}
}
return model;
} catch (e) {
throw Exception('Error al guardar la configuración: $e');
}
}
}
@@ -0,0 +1,102 @@
import 'package:mobx/mobx.dart';
import '../../../dashboard/data/models/doctor_info_model.dart';
import '../../data/services/config_service.dart';
part 'config_store.g.dart';
// ignore: library_private_types_in_public_api
class ConfigStore = _ConfigStoreBase with _$ConfigStore;
abstract class _ConfigStoreBase with Store {
final ConfigService service;
_ConfigStoreBase({ConfigService? configService})
: service = configService ?? ConfigService();
@observable
int? id;
@observable
bool isLoading = false;
@observable
bool isSaving = false;
@observable
String? errorMessage;
@observable
String? successMessage;
@observable
String nombreDoctor = '';
@observable
String especialidad = '';
@observable
String descripEspecialidad = '';
@observable
String descripGeneral = '';
@computed
bool get isValid => nombreDoctor.trim().isNotEmpty && especialidad.trim().isNotEmpty;
@action
void initFromModel(DoctorInfoModel model) {
id = model.id;
nombreDoctor = model.nombreDoctor;
especialidad = model.especialidad;
descripEspecialidad = model.descripEspecialidad;
descripGeneral = model.descripGeneral;
errorMessage = null;
successMessage = null;
}
@action
Future<void> loadConfig() async {
isLoading = true;
errorMessage = null;
try {
final config = await service.getConfig();
initFromModel(config);
} catch (e) {
errorMessage = e.toString();
} finally {
isLoading = false;
}
}
@action
Future<DoctorInfoModel?> saveConfig() async {
if (!isValid) {
errorMessage = 'El nombre del doctor y la especialidad son obligatorios.';
return null;
}
isSaving = true;
errorMessage = null;
successMessage = null;
try {
final modelToSave = DoctorInfoModel(
id: id,
nombreDoctor: nombreDoctor.trim(),
especialidad: especialidad.trim(),
descripEspecialidad: descripEspecialidad.trim(),
descripGeneral: descripGeneral.trim(),
);
final updated = await service.updateConfig(modelToSave);
initFromModel(updated);
successMessage = 'Configuración guardada exitosamente';
return updated;
} catch (e) {
errorMessage = e.toString().replaceAll('Exception: ', '');
return null;
} finally {
isSaving = false;
}
}
}
@@ -0,0 +1,231 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config_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 _$ConfigStore on _ConfigStoreBase, Store {
Computed<bool>? _$isValidComputed;
@override
bool get isValid => (_$isValidComputed ??= Computed<bool>(
() => super.isValid,
name: '_ConfigStoreBase.isValid',
)).value;
late final _$idAtom = Atom(name: '_ConfigStoreBase.id', context: context);
@override
int? get id {
_$idAtom.reportRead();
return super.id;
}
@override
set id(int? value) {
_$idAtom.reportWrite(value, super.id, () {
super.id = value;
});
}
late final _$isLoadingAtom = Atom(
name: '_ConfigStoreBase.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 _$isSavingAtom = Atom(
name: '_ConfigStoreBase.isSaving',
context: context,
);
@override
bool get isSaving {
_$isSavingAtom.reportRead();
return super.isSaving;
}
@override
set isSaving(bool value) {
_$isSavingAtom.reportWrite(value, super.isSaving, () {
super.isSaving = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_ConfigStoreBase.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 _$successMessageAtom = Atom(
name: '_ConfigStoreBase.successMessage',
context: context,
);
@override
String? get successMessage {
_$successMessageAtom.reportRead();
return super.successMessage;
}
@override
set successMessage(String? value) {
_$successMessageAtom.reportWrite(value, super.successMessage, () {
super.successMessage = value;
});
}
late final _$nombreDoctorAtom = Atom(
name: '_ConfigStoreBase.nombreDoctor',
context: context,
);
@override
String get nombreDoctor {
_$nombreDoctorAtom.reportRead();
return super.nombreDoctor;
}
@override
set nombreDoctor(String value) {
_$nombreDoctorAtom.reportWrite(value, super.nombreDoctor, () {
super.nombreDoctor = value;
});
}
late final _$especialidadAtom = Atom(
name: '_ConfigStoreBase.especialidad',
context: context,
);
@override
String get especialidad {
_$especialidadAtom.reportRead();
return super.especialidad;
}
@override
set especialidad(String value) {
_$especialidadAtom.reportWrite(value, super.especialidad, () {
super.especialidad = value;
});
}
late final _$descripEspecialidadAtom = Atom(
name: '_ConfigStoreBase.descripEspecialidad',
context: context,
);
@override
String get descripEspecialidad {
_$descripEspecialidadAtom.reportRead();
return super.descripEspecialidad;
}
@override
set descripEspecialidad(String value) {
_$descripEspecialidadAtom.reportWrite(value, super.descripEspecialidad, () {
super.descripEspecialidad = value;
});
}
late final _$descripGeneralAtom = Atom(
name: '_ConfigStoreBase.descripGeneral',
context: context,
);
@override
String get descripGeneral {
_$descripGeneralAtom.reportRead();
return super.descripGeneral;
}
@override
set descripGeneral(String value) {
_$descripGeneralAtom.reportWrite(value, super.descripGeneral, () {
super.descripGeneral = value;
});
}
late final _$loadConfigAsyncAction = AsyncAction(
'_ConfigStoreBase.loadConfig',
context: context,
);
@override
Future<void> loadConfig() {
return _$loadConfigAsyncAction.run(() => super.loadConfig());
}
late final _$saveConfigAsyncAction = AsyncAction(
'_ConfigStoreBase.saveConfig',
context: context,
);
@override
Future<DoctorInfoModel?> saveConfig() {
return _$saveConfigAsyncAction.run(() => super.saveConfig());
}
late final _$_ConfigStoreBaseActionController = ActionController(
name: '_ConfigStoreBase',
context: context,
);
@override
void initFromModel(DoctorInfoModel model) {
final _$actionInfo = _$_ConfigStoreBaseActionController.startAction(
name: '_ConfigStoreBase.initFromModel',
);
try {
return super.initFromModel(model);
} finally {
_$_ConfigStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
id: ${id},
isLoading: ${isLoading},
isSaving: ${isSaving},
errorMessage: ${errorMessage},
successMessage: ${successMessage},
nombreDoctor: ${nombreDoctor},
especialidad: ${especialidad},
descripEspecialidad: ${descripEspecialidad},
descripGeneral: ${descripGeneral},
isValid: ${isValid}
''';
}
}
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/medical_modal.dart';
import '../../../dashboard/data/models/doctor_info_model.dart';
import '../store/config_store.dart';
class ConfigModalView extends StatefulWidget {
final ValueChanged<DoctorInfoModel>? onSaved;
final ConfigStore? store;
const ConfigModalView({
super.key,
this.onSaved,
this.store,
});
@override
State<ConfigModalView> createState() => _ConfigModalViewState();
}
class _ConfigModalViewState extends State<ConfigModalView> {
late final ConfigStore _store;
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
_store = widget.store ?? ConfigStore();
if (widget.store == null) {
_store.loadConfig();
}
}
Future<void> _handleSave() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
if (values['nombre_doctor'] != null) {
_store.nombreDoctor = values['nombre_doctor'].toString();
}
if (values['especialidad'] != null) {
_store.especialidad = values['especialidad'].toString();
}
if (values['descrip_especialidad'] != null) {
_store.descripEspecialidad = values['descrip_especialidad'].toString();
}
if (values['descrip_general'] != null) {
_store.descripGeneral = values['descrip_general'].toString();
}
final updated = await _store.saveConfig();
if (updated != null && mounted) {
widget.onSaved?.call(updated);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(Icons.check_circle_rounded, color: Colors.white, size: 18),
SizedBox(width: 8),
Expanded(
child: Text('Configuración del consultorio guardada correctamente'),
),
],
),
backgroundColor: Color(0xFF00A65A),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 3),
),
);
Navigator.of(context).pop();
}
}
}
InputDecoration _cleanDecoration({
required String label,
IconData? icon,
bool isRequired = false,
String? hintText,
}) {
return InputDecoration(
labelText: isRequired ? '$label *' : label,
hintText: hintText,
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
prefixIcon: icon != null
? Icon(icon, size: 18, color: isRequired ? AppColors.primary : AppColors.textMuted)
: null,
prefixIconConstraints: const BoxConstraints(minWidth: 38, minHeight: 38),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
),
labelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isRequired ? AppColors.primary : AppColors.textSecondary,
),
hintStyle: const TextStyle(fontSize: 12.5, color: Color(0xFF94A3B8)),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
return MedicalModalDialog(
titleIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.settings_suggest_rounded, color: AppColors.primary, size: 22),
),
title: 'Configuración del Consultorio',
subtitle: const Text(
'Actualice la información del doctor, especialidad y datos de contacto de la clínica',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
maxWidth: 780,
maxHeight: 660,
bottomBar: _buildBottomBar(),
child: _store.isLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
),
)
: _buildForm(),
);
},
);
}
Widget _buildBottomBar() {
return 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.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 Spacer(),
TextButton(
onPressed: _store.isSaving ? null : () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text('Cancelar', style: TextStyle(fontWeight: FontWeight.w600)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _store.isSaving ? null : _handleSave,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00A65A),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _store.isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline_rounded, size: 18),
SizedBox(width: 8),
Text(
'Guardar Configuración',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700),
),
],
),
),
],
),
);
}
Widget _buildForm() {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: FormBuilder(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_store.errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.error.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_store.errorMessage!,
style: const TextStyle(fontSize: 12.5, color: AppColors.error, fontWeight: FontWeight.w500),
),
),
],
),
),
],
// Sección 1: Identidad Médica
_buildSectionHeader(
icon: Icons.person_pin_rounded,
title: 'Identidad del Profesional',
subtitle: 'Nombre y especialidad principal reflejados en el encabezado y recetas',
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: FormBuilderTextField(
name: 'nombre_doctor',
initialValue: _store.nombreDoctor,
decoration: _cleanDecoration(
label: 'Nombre del Doctor',
icon: Icons.badge_outlined,
isRequired: true,
hintText: 'Ej. Dr. Luis A. Castro Sosa',
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'Requerido'),
FormBuilderValidators.minLength(3, errorText: 'Mínimo 3 caracteres'),
]),
onChanged: (v) => _store.nombreDoctor = v ?? '',
),
),
const SizedBox(width: 14),
Expanded(
flex: 5,
child: FormBuilderTextField(
name: 'especialidad',
initialValue: _store.especialidad,
decoration: _cleanDecoration(
label: 'Especialidad',
icon: Icons.medical_services_outlined,
isRequired: true,
hintText: 'Ej. Cirujano Urólogo',
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'Requerido'),
FormBuilderValidators.minLength(3, errorText: 'Mínimo 3 caracteres'),
]),
onChanged: (v) => _store.especialidad = v ?? '',
),
),
],
),
const SizedBox(height: 22),
// Sección 2: Áreas de Enfoque y Subespecialidades
_buildSectionHeader(
icon: Icons.local_hospital_outlined,
title: 'Áreas de Enfoque y Subespecialidades',
subtitle: 'Enfermedades, órganos o áreas de tratamiento de su consulta',
),
const SizedBox(height: 12),
FormBuilderTextField(
name: 'descrip_especialidad',
initialValue: _store.descripEspecialidad,
maxLines: 4,
decoration: _cleanDecoration(
label: 'Descripción de Especialidades',
hintText: 'Ej. Enfermedades Vías Urinarias, Riñones, Vejiga, Próstata, Disfunción eréctil...',
),
onChanged: (v) => _store.descripEspecialidad = v ?? '',
),
const SizedBox(height: 22),
// Sección 3: Datos de Contacto y Clínica
_buildSectionHeader(
icon: Icons.apartment_rounded,
title: 'Datos del Consultorio y Contacto',
subtitle: 'Dirección física de la clínica, edificio, teléfonos, celular y correo',
),
const SizedBox(height: 12),
FormBuilderTextField(
name: 'descrip_general',
initialValue: _store.descripGeneral,
maxLines: 4,
decoration: _cleanDecoration(
label: 'Información General y Contacto',
hintText: 'Ej. Instituto materno infantil, Av. Juan Pablo Duarte, Santiago. Tel: 809-583-2394...',
),
onChanged: (v) => _store.descripGeneral = v ?? '',
),
],
),
),
);
}
Widget _buildSectionHeader({
required IconData icon,
required String title,
required String subtitle,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textMuted,
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,39 @@
class DashboardMetricsModel {
final int nuevosPacientes;
final int consultadosHoy;
final int citasHoy;
final int pacientesMasculinos;
final int pacientesFemeninos;
final int totalPacientes;
const DashboardMetricsModel({
this.nuevosPacientes = 0,
this.consultadosHoy = 0,
this.citasHoy = 0,
this.pacientesMasculinos = 11078,
this.pacientesFemeninos = 2634,
this.totalPacientes = 13712,
});
factory DashboardMetricsModel.fromJson(Map<String, dynamic> json) {
return DashboardMetricsModel(
nuevosPacientes: json['nuevos_pacientes'] as int? ?? 0,
consultadosHoy: json['consultados_hoy'] as int? ?? 0,
citasHoy: json['citas_hoy'] as int? ?? 0,
pacientesMasculinos: json['pacientes_masculinos'] as int? ?? 0,
pacientesFemeninos: json['pacientes_femeninos'] as int? ?? 0,
totalPacientes: json['total_pacientes'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'nuevos_pacientes': nuevosPacientes,
'consultados_hoy': consultadosHoy,
'citas_hoy': citasHoy,
'pacientes_masculinos': pacientesMasculinos,
'pacientes_femeninos': pacientesFemeninos,
'total_pacientes': totalPacientes,
};
}
}
@@ -0,0 +1,57 @@
class DoctorInfoModel {
final int? id;
final String nombreDoctor;
final String especialidad;
final String descripEspecialidad;
final String descripGeneral;
const DoctorInfoModel({
this.id,
required this.nombreDoctor,
required this.especialidad,
required this.descripEspecialidad,
required this.descripGeneral,
});
factory DoctorInfoModel.fromJson(Map<String, dynamic> json) {
final doctor = (json['nombre_doctor']?.toString() ?? 'Dr. Luis A. Castro Sosa').trim();
final esp = (json['especialidad']?.toString() ?? 'Cirujano Urólogo').trim();
return DoctorInfoModel(
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id']?.toString() ?? ''),
nombreDoctor: doctor.isEmpty ? 'Dr. Luis A. Castro Sosa' : doctor,
especialidad: esp.isEmpty ? 'Cirujano Urólogo' : esp,
descripEspecialidad: json['descrip_especialidad']?.toString() ?? '',
descripGeneral: json['descrip_general']?.toString() ?? '',
);
}
Map<String, dynamic> toJson({bool includeId = false}) {
final map = <String, dynamic>{
'nombre_doctor': nombreDoctor,
'especialidad': especialidad,
'descrip_especialidad': descripEspecialidad,
'descrip_general': descripGeneral,
};
if (includeId && id != null) {
map['id'] = id;
}
return map;
}
DoctorInfoModel copyWith({
int? id,
String? nombreDoctor,
String? especialidad,
String? descripEspecialidad,
String? descripGeneral,
}) {
return DoctorInfoModel(
id: id ?? this.id,
nombreDoctor: nombreDoctor ?? this.nombreDoctor,
especialidad: especialidad ?? this.especialidad,
descripEspecialidad: descripEspecialidad ?? this.descripEspecialidad,
descripGeneral: descripGeneral ?? this.descripGeneral,
);
}
}
@@ -0,0 +1,109 @@
class PatientSummaryModel {
final int id;
final String nombre;
final String apellido;
final String cedula;
final String telefono;
final String fecReg;
final String fecProxCita;
final String seguro;
final String motCons;
final String estadoEspera;
const PatientSummaryModel({
required this.id,
required this.nombre,
required this.apellido,
required this.cedula,
required this.telefono,
required this.fecReg,
required this.fecProxCita,
required this.seguro,
required this.motCons,
this.estadoEspera = 'no_presente',
});
String get fullName => '$nombre $apellido'.trim();
bool get isNoPresente => estadoEspera == 'no_presente' || estadoEspera.isEmpty;
bool get isEnEspera => estadoEspera == 'en_espera';
bool get isEnConsulta => estadoEspera == 'en_consulta';
bool get isAtendido => estadoEspera == 'atendido';
String get estadoEsperaLabel {
switch (estadoEspera) {
case 'en_espera':
return 'En Sala de Espera';
case 'en_consulta':
return 'En Consulta';
case 'atendido':
return 'Atendido';
case 'no_presente':
default:
return 'No Presente';
}
}
PatientSummaryModel copyWith({
int? id,
String? nombre,
String? apellido,
String? cedula,
String? telefono,
String? fecReg,
String? fecProxCita,
String? seguro,
String? motCons,
String? estadoEspera,
}) {
return PatientSummaryModel(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
apellido: apellido ?? this.apellido,
cedula: cedula ?? this.cedula,
telefono: telefono ?? this.telefono,
fecReg: fecReg ?? this.fecReg,
fecProxCita: fecProxCita ?? this.fecProxCita,
seguro: seguro ?? this.seguro,
motCons: motCons ?? this.motCons,
estadoEspera: estadoEspera ?? this.estadoEspera,
);
}
factory PatientSummaryModel.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 PatientSummaryModel(
id: parsedId,
nombre: json['nombre']?.toString() ?? '',
apellido: json['apellido']?.toString() ?? '',
cedula: json['cedula']?.toString() ?? '',
telefono: json['telefono']?.toString() ?? '',
fecReg: json['fec_reg']?.toString() ?? '',
fecProxCita: json['fec_proxcita']?.toString() ?? '',
seguro: json['seguro']?.toString() ?? '',
motCons: json['mot_cons']?.toString() ?? '',
estadoEspera: json['estado_espera']?.toString() ?? 'no_presente',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nombre': nombre,
'apellido': apellido,
'cedula': cedula,
'telefono': telefono,
'fec_reg': fecReg,
'fec_proxcita': fecProxCita,
'seguro': seguro,
'mot_cons': motCons,
'estado_espera': estadoEspera,
};
}
}
@@ -0,0 +1,188 @@
import '../../../../core/network/api_client.dart';
import '../models/dashboard_metrics_model.dart';
import '../models/doctor_info_model.dart';
import '../models/patient_summary_model.dart';
class DashboardService {
final ApiClient apiClient;
DashboardService({ApiClient? client}) : apiClient = client ?? ApiClient();
String _formatTwoDigits(int n) => n.toString().padLeft(2, '0');
Future<DoctorInfoModel> getDoctorConfig() async {
try {
final response = await apiClient.dio.get('/items/configuracion');
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>;
return DoctorInfoModel.fromJson(data);
}
return const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
} catch (_) {
return const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
}
}
Future<DashboardMetricsModel> getDashboardMetrics() async {
try {
final now = DateTime.now();
final day = _formatTwoDigits(now.day);
final month = _formatTwoDigits(now.month);
final year = now.year.toString();
final dmy = '$day/$month/$year';
final iso = '$year-$month-$day';
// Consultas concurrentes optimizadas a Directus
final results = await Future.wait([
// 0: Desglose por sexo
apiClient.dio.get('/items/pacientes?aggregate[count]=*&groupBy[]=sexo'),
// 1: Citas para hoy (condición estricta: fec_proxcita igual a la fecha del día actual)
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso',
),
// 2: Pacientes nuevos registrados hoy
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_reg][_contains]=$dmy&filter[_or][1][fec_reg][_contains]=$iso',
),
// 3: Pacientes consultados hoy
apiClient.dio.get(
'/items/pacientes?aggregate[count]=*&filter[_or][0][fec_ultcon][_contains]=$dmy&filter[_or][1][fec_ultcon][_contains]=$iso',
),
// 4: Total de pacientes
apiClient.dio.get('/items/pacientes?aggregate[count]=*'),
]);
int masc = 11078;
int fem = 2634;
int citasHoy = 0;
int nuevosHoy = 0;
int consultadosHoy = 0;
int total = 13712;
// 0. Desglose por sexo
if (results[0].statusCode == 200 && results[0].data != null) {
final sexoData = results[0].data['data'] as List<dynamic>? ?? [];
for (final item in sexoData) {
final s = item['sexo']?.toString().toLowerCase() ?? '';
final count = int.tryParse(item['count'].toString()) ?? 0;
if (s.contains('masc')) masc = count;
if (s.contains('fem')) fem = count;
}
}
// 1. Citas para hoy
if (results[1].statusCode == 200 && results[1].data != null) {
final data = results[1].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
citasHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 2. Nuevos hoy
if (results[2].statusCode == 200 && results[2].data != null) {
final data = results[2].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
nuevosHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 3. Consultados hoy
if (results[3].statusCode == 200 && results[3].data != null) {
final data = results[3].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
consultadosHoy = int.tryParse(data[0]['count'].toString()) ?? 0;
}
}
// 4. Total pacientes
if (results[4].statusCode == 200 && results[4].data != null) {
final data = results[4].data['data'] as List<dynamic>? ?? [];
if (data.isNotEmpty) {
total = int.tryParse(data[0]['count'].toString()) ?? 13712;
}
}
return DashboardMetricsModel(
nuevosPacientes: nuevosHoy,
consultadosHoy: consultadosHoy,
citasHoy: citasHoy,
pacientesMasculinos: masc,
pacientesFemeninos: fem,
totalPacientes: total,
);
} catch (_) {
return const DashboardMetricsModel();
}
}
Future<List<PatientSummaryModel>> getRecentPatients({int limit = 5}) async {
try {
final response = await apiClient.dio.get(
'/items/pacientes?sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_reg,fec_proxcita,seguro,mot_cons,estado_espera',
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as List<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> getAppointmentsByDate(DateTime date, {int limit = 100}) async {
try {
final day = _formatTwoDigits(date.day);
final month = _formatTwoDigits(date.month);
final year = date.year.toString();
final dmy = '$day/$month/$year';
final iso = '$year-$month-$day';
final response = await apiClient.dio.get(
'/items/pacientes?filter[_or][0][fec_proxcita][_eq]=$dmy&filter[_or][1][fec_proxcita][_starts_with]=$dmy&filter[_or][2][fec_proxcita][_starts_with]=$iso&sort=-id&limit=$limit&fields=id,nombre,apellido,cedula,telefono,fec_proxcita,mot_cons,estado_espera',
);
final data = response.data?['data'] as List<dynamic>? ?? [];
return data
.map((item) => PatientSummaryModel.fromJson(item as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<List<PatientSummaryModel>> getTodayAppointments({int limit = 50}) async {
return getAppointmentsByDate(DateTime.now(), limit: limit);
}
Future<bool> updatePatientWaitingStatus(int patientId, String nuevoEstado) async {
try {
final response = await apiClient.dio.patch(
'/items/pacientes/$patientId',
data: {'estado_espera': nuevoEstado},
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
}
@@ -0,0 +1,90 @@
import 'package:mobx/mobx.dart';
import '../../data/models/dashboard_metrics_model.dart';
import '../../data/models/doctor_info_model.dart';
import '../../data/models/patient_summary_model.dart';
import '../../data/services/dashboard_service.dart';
part 'dashboard_store.g.dart';
// ignore: library_private_types_in_public_api
class DashboardStore = _DashboardStoreBase with _$DashboardStore;
abstract class _DashboardStoreBase with Store {
final DashboardService service;
_DashboardStoreBase({DashboardService? dashboardService})
: service = dashboardService ?? DashboardService();
@observable
bool isLoading = true;
@observable
DoctorInfoModel doctorInfo = const DoctorInfoModel(
nombreDoctor: 'Dr. Luis A. Castro Sosa',
especialidad: 'Cirujano Urólogo',
descripEspecialidad: '',
descripGeneral: '',
);
@observable
DashboardMetricsModel metrics = const DashboardMetricsModel();
@observable
ObservableList<PatientSummaryModel> recentPatients = ObservableList<PatientSummaryModel>();
@observable
ObservableList<PatientSummaryModel> todayAppointments = ObservableList<PatientSummaryModel>();
@observable
String? errorMessage;
int get enEsperaCount =>
todayAppointments.where((a) => a.isEnEspera).length;
@action
Future<void> loadDashboardData() async {
isLoading = true;
errorMessage = null;
try {
final config = await service.getDoctorConfig();
final metricsData = await service.getDashboardMetrics();
final recents = await service.getRecentPatients();
final appts = await service.getTodayAppointments();
runInAction(() {
doctorInfo = config;
metrics = metricsData;
recentPatients = ObservableList.of(recents);
todayAppointments = ObservableList.of(appts);
isLoading = false;
});
} catch (e) {
runInAction(() {
errorMessage = e.toString();
isLoading = false;
});
}
}
@action
Future<bool> changeWaitingStatus(int patientId, String nuevoEstado) async {
final success = await service.updatePatientWaitingStatus(patientId, nuevoEstado);
if (success) {
runInAction(() {
final index = todayAppointments.indexWhere((p) => p.id == patientId);
if (index != -1) {
todayAppointments[index] =
todayAppointments[index].copyWith(estadoEspera: nuevoEstado);
}
final recentIndex = recentPatients.indexWhere((p) => p.id == patientId);
if (recentIndex != -1) {
recentPatients[recentIndex] =
recentPatients[recentIndex].copyWith(estadoEspera: nuevoEstado);
}
});
}
return success;
}
}
@@ -0,0 +1,153 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dashboard_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 _$DashboardStore on _DashboardStoreBase, Store {
late final _$isLoadingAtom = Atom(
name: '_DashboardStoreBase.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 _$doctorInfoAtom = Atom(
name: '_DashboardStoreBase.doctorInfo',
context: context,
);
@override
DoctorInfoModel get doctorInfo {
_$doctorInfoAtom.reportRead();
return super.doctorInfo;
}
@override
set doctorInfo(DoctorInfoModel value) {
_$doctorInfoAtom.reportWrite(value, super.doctorInfo, () {
super.doctorInfo = value;
});
}
late final _$metricsAtom = Atom(
name: '_DashboardStoreBase.metrics',
context: context,
);
@override
DashboardMetricsModel get metrics {
_$metricsAtom.reportRead();
return super.metrics;
}
@override
set metrics(DashboardMetricsModel value) {
_$metricsAtom.reportWrite(value, super.metrics, () {
super.metrics = value;
});
}
late final _$recentPatientsAtom = Atom(
name: '_DashboardStoreBase.recentPatients',
context: context,
);
@override
ObservableList<PatientSummaryModel> get recentPatients {
_$recentPatientsAtom.reportRead();
return super.recentPatients;
}
@override
set recentPatients(ObservableList<PatientSummaryModel> value) {
_$recentPatientsAtom.reportWrite(value, super.recentPatients, () {
super.recentPatients = value;
});
}
late final _$todayAppointmentsAtom = Atom(
name: '_DashboardStoreBase.todayAppointments',
context: context,
);
@override
ObservableList<PatientSummaryModel> get todayAppointments {
_$todayAppointmentsAtom.reportRead();
return super.todayAppointments;
}
@override
set todayAppointments(ObservableList<PatientSummaryModel> value) {
_$todayAppointmentsAtom.reportWrite(value, super.todayAppointments, () {
super.todayAppointments = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_DashboardStoreBase.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 _$loadDashboardDataAsyncAction = AsyncAction(
'_DashboardStoreBase.loadDashboardData',
context: context,
);
@override
Future<void> loadDashboardData() {
return _$loadDashboardDataAsyncAction.run(() => super.loadDashboardData());
}
late final _$changeWaitingStatusAsyncAction = AsyncAction(
'_DashboardStoreBase.changeWaitingStatus',
context: context,
);
@override
Future<bool> changeWaitingStatus(int patientId, String nuevoEstado) {
return _$changeWaitingStatusAsyncAction.run(
() => super.changeWaitingStatus(patientId, nuevoEstado),
);
}
@override
String toString() {
return '''
isLoading: ${isLoading},
doctorInfo: ${doctorInfo},
metrics: ${metrics},
recentPatients: ${recentPatients},
todayAppointments: ${todayAppointments},
errorMessage: ${errorMessage}
''';
}
}
@@ -0,0 +1,960 @@
import 'package:flutter/material.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_summary_model.dart';
import '../../data/services/dashboard_service.dart';
class AppointmentsModalView extends StatefulWidget {
final DateTime? initialDate;
final DashboardService? service;
final bool isSecretary;
final void Function(int patientId, String newStatus)? onStatusChanged;
const AppointmentsModalView({
super.key,
this.initialDate,
this.service,
this.isSecretary = false,
this.onStatusChanged,
});
@override
State<AppointmentsModalView> createState() => _AppointmentsModalViewState();
}
class _AppointmentsModalViewState extends State<AppointmentsModalView> {
late final DashboardService _service;
late DateTime _selectedDate;
bool _isLoading = false;
String? _errorMessage;
List<PatientSummaryModel> _appointments = [];
String _waitingFilter = 'todos'; // 'todos', 'en_espera', 'en_consulta', 'atendido', 'no_presente'
@override
void initState() {
super.initState();
_service = widget.service ?? DashboardService();
_selectedDate = widget.initialDate ?? DateTime.now();
_fetchAppointments(_selectedDate);
}
Future<void> _handleStatusChange(PatientSummaryModel item, String newStatus) async {
final success = await _service.updatePatientWaitingStatus(item.id, newStatus);
if (success && mounted) {
setState(() {
final index = _appointments.indexWhere((p) => p.id == item.id);
if (index != -1) {
_appointments[index] = _appointments[index].copyWith(estadoEspera: newStatus);
}
});
if (widget.onStatusChanged != null) {
widget.onStatusChanged!(item.id, newStatus);
}
}
}
List<PatientSummaryModel> get _filteredAppointments {
if (_waitingFilter == 'todos') return _appointments;
if (_waitingFilter == 'en_espera') return _appointments.where((a) => a.isEnEspera).toList();
if (_waitingFilter == 'en_consulta') return _appointments.where((a) => a.isEnConsulta).toList();
if (_waitingFilter == 'atendido') return _appointments.where((a) => a.isAtendido).toList();
if (_waitingFilter == 'no_presente') return _appointments.where((a) => a.isNoPresente).toList();
return _appointments;
}
bool _isToday(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month && date.day == now.day;
}
String _formatDate(DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year.toString();
return '$day/$month/$year';
}
String _formatDateHuman(DateTime date) {
const weekdays = [
'Lunes',
'Martes',
'Miércoles',
'Jueves',
'Viernes',
'Sábado',
'Domingo'
];
const months = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre'
];
final weekdayName = weekdays[date.weekday - 1];
final monthName = months[date.month - 1];
return '$weekdayName, ${date.day} de $monthName de ${date.year}';
}
Future<void> _fetchAppointments(DateTime date) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final results = await _service.getAppointmentsByDate(date);
if (mounted) {
setState(() {
_appointments = results;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Error al cargar las citas para la fecha seleccionada: $e';
_isLoading = false;
});
}
}
}
void _onDateSelected(DateTime newDate) {
if (newDate.year == _selectedDate.year &&
newDate.month == _selectedDate.month &&
newDate.day == _selectedDate.day) {
return;
}
setState(() {
_selectedDate = newDate;
});
_fetchAppointments(newDate);
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2050),
helpText: 'SELECCIONAR FECHA DE CITAS',
confirmText: 'FILTRAR',
cancelText: 'CANCELAR',
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
onSurface: AppColors.textPrimary,
),
),
child: child!,
);
},
);
if (picked != null) {
_onDateSelected(picked);
}
}
void _openPatient(int patientId) {
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
patientId: patientId,
isSecretary: widget.isSecretary,
onSaved: (_) {
_fetchAppointments(_selectedDate);
if (widget.onStatusChanged != null) {
widget.onStatusChanged!(patientId, 'atendido');
}
},
),
);
}
@override
Widget build(BuildContext context) {
final isFilteringToday = _isToday(_selectedDate);
return MedicalModalDialog(
titleIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.event_available_rounded,
color: AppColors.warning,
size: 22,
),
),
title: 'Agenda de Citas Médicas',
subtitle: const Text(
'Consulte y gestione los pacientes citados por fecha de consulta',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
maxWidth: 820,
maxHeight: 700,
bottomBar: _buildBottomBar(),
child: Column(
children: [
// Barra de Filtro de Fecha
_buildDateFilterBar(isFilteringToday),
// Divisor
const Divider(height: 1, color: Color(0xFFE2E8F0)),
// Contenido Principal (Lista de Citas)
Expanded(
child: _buildBody(isFilteringToday),
),
],
),
);
}
Widget _buildDateFilterBar(bool isFilteringToday) {
final filterText = isFilteringToday
? 'Hoy (${_formatDate(_selectedDate)})'
: _formatDate(_selectedDate);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
// Indicador dinámico de filtro activo (Hoy vs Fecha Específica)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isFilteringToday
? AppColors.primaryContainer
: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isFilteringToday
? AppColors.primary.withValues(alpha: 0.3)
: const Color(0xFF93C5FD),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isFilteringToday ? Icons.today_rounded : Icons.date_range_rounded,
size: 16,
color: isFilteringToday ? AppColors.primary : const Color(0xFF2563EB),
),
const SizedBox(width: 8),
Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Filtro: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
TextSpan(
text: filterText,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: isFilteringToday
? AppColors.primary
: const Color(0xFF1D4ED8),
),
),
],
),
),
],
),
),
// Botones de Navegación Rápida
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Botón Día Anterior
IconButton(
icon: const Icon(Icons.chevron_left_rounded, size: 20),
tooltip: 'Día anterior',
splashRadius: 18,
onPressed: _isLoading
? null
: () => _onDateSelected(_selectedDate.subtract(const Duration(days: 1))),
),
// Botón acceso directo a "Hoy"
InkWell(
onTap: _isLoading || isFilteringToday
? null
: () => _onDateSelected(DateTime.now()),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isFilteringToday
? AppColors.primary
: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isFilteringToday
? AppColors.primary
: const Color(0xFFCBD5E1),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh_rounded,
size: 14,
color: isFilteringToday ? Colors.white : AppColors.textPrimary,
),
const SizedBox(width: 4),
Text(
'Hoy',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isFilteringToday ? Colors.white : AppColors.textPrimary,
),
),
],
),
),
),
// Botón Día Siguiente
IconButton(
icon: const Icon(Icons.chevron_right_rounded, size: 20),
tooltip: 'Día siguiente',
splashRadius: 18,
onPressed: _isLoading
? null
: () => _onDateSelected(_selectedDate.add(const Duration(days: 1))),
),
const SizedBox(width: 6),
// Botón Selector de Fecha
ElevatedButton.icon(
onPressed: _isLoading ? null : _pickDate,
icon: const Icon(Icons.calendar_month_outlined, size: 16),
label: const Text(
'Cambiar Fecha',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.textPrimary,
elevation: 0,
side: const BorderSide(color: Color(0xFFCBD5E1)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
),
],
),
],
),
const SizedBox(height: 6),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 6,
children: [
Text(
_formatDateHuman(_selectedDate),
style: const TextStyle(
fontSize: 11.5,
color: AppColors.textMuted,
fontWeight: FontWeight.w500,
),
),
// Píldoras de Filtro de Sala de Espera
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildFilterChip('todos', 'Todos (${_appointments.length})', null),
_buildFilterChip(
'en_espera',
'En Espera (${_appointments.where((a) => a.isEnEspera).length})',
const Color(0xFF15803D),
),
_buildFilterChip(
'en_consulta',
'En Consulta (${_appointments.where((a) => a.isEnConsulta).length})',
const Color(0xFF0369A1),
),
_buildFilterChip(
'atendido',
'Atendidos (${_appointments.where((a) => a.isAtendido).length})',
const Color(0xFF64748B),
),
],
),
],
),
],
),
);
}
Widget _buildFilterChip(String value, String label, Color? highlightColor) {
final isSelected = _waitingFilter == value;
return InkWell(
onTap: () {
setState(() {
_waitingFilter = value;
});
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? (highlightColor ?? AppColors.primary).withValues(alpha: 0.15)
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? (highlightColor ?? AppColors.primary)
: const Color(0xFFE2E8F0),
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (highlightColor ?? AppColors.primary)
: AppColors.textSecondary,
),
),
),
);
}
Widget _buildBody(bool isFilteringToday) {
if (_isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 14),
Text(
'Consultando agenda médica...',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 40),
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppColors.error),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _fetchAppointments(_selectedDate),
icon: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Reintentar'),
),
],
),
),
);
}
if (_appointments.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(50),
),
child: const Icon(
Icons.event_busy_rounded,
size: 42,
color: AppColors.textMuted,
),
),
const SizedBox(height: 16),
Text(
isFilteringToday
? 'No hay citas programadas para el día de hoy'
: 'No hay citas registradas para el ${_formatDate(_selectedDate)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
Text(
isFilteringToday
? 'Puede consultar otra fecha utilizando el selector de calendario superior.'
: 'Intente seleccionar otro día o presione "Hoy" para volver a la agenda actual.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
);
}
final list = _filteredAppointments;
if (list.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.filter_list_off_rounded, size: 38, color: AppColors.textMuted),
const SizedBox(height: 12),
Text(
'No hay pacientes con el filtro seleccionado',
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.textSecondary),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => setState(() => _waitingFilter = 'todos'),
child: const Text('Mostrar todos'),
),
],
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
itemCount: list.length,
separatorBuilder: (_, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = list[index];
return _buildAppointmentTile(item);
},
);
}
Widget _buildAppointmentTile(PatientSummaryModel item) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: item.isEnEspera ? const Color(0xFF86EFAC) : const Color(0xFFE2E8F0),
width: item.isEnEspera ? 1.5 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: item.id > 0 ? () => _openPatient(item.id) : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
// Icono de Cita
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: item.isEnEspera
? const Color(0xFFDCFCE7)
: AppColors.teal.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.isEnEspera ? Icons.chair_rounded : Icons.calendar_today_rounded,
color: item.isEnEspera ? const Color(0xFF15803D) : AppColors.teal,
size: 20,
),
),
const SizedBox(width: 14),
// Datos del Paciente y Motivo
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.fullName,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (item.cedula.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.cedula,
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
if (item.telefono.isNotEmpty) ...[
const Icon(Icons.phone_outlined, size: 13, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
item.telefono,
style: const TextStyle(fontSize: 11.5, color: AppColors.textSecondary),
),
const SizedBox(width: 12),
],
const Icon(Icons.medical_information_outlined, size: 13, color: AppColors.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
item.motCons.isNotEmpty
? item.motCons.replaceAll('\n', ' ').trim()
: 'Consulta médica programada',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: AppColors.textMuted),
),
),
],
),
],
),
),
const SizedBox(width: 12),
// Acciones de Sala de Espera / Estado
_buildAppointmentActions(item),
const SizedBox(width: 8),
// Botón Ver Ficha / Abrir Historial
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.arrow_forward_ios_rounded,
color: AppColors.primary,
size: 14,
),
),
],
),
),
),
),
);
}
Widget _buildAppointmentActions(PatientSummaryModel item) {
const double uniformHeight = 28.0;
const double uniformRadius = 6.0;
if (item.isEnEspera) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF86EFAC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)),
SizedBox(width: 4),
Text(
'En Espera',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
if (!widget.isSecretary) ...[
const SizedBox(width: 6),
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () async {
await _handleStatusChange(item, 'en_consulta');
_openPatient(item.id);
},
icon: const Icon(Icons.arrow_forward_rounded, size: 12),
label: const Text('Atender', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
] else ...[
const SizedBox(width: 4),
Tooltip(
message: 'Quitar de la sala de espera',
child: InkWell(
onTap: () => _handleStatusChange(item, 'no_presente'),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
width: uniformHeight,
alignment: Alignment.center,
child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted),
),
),
),
],
],
);
}
if (item.isEnConsulta) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _openPatient(item.id),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFE0F2FE),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF7DD3FC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)),
SizedBox(width: 4),
Text(
'En Consulta',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF0369A1)),
),
],
),
),
),
if (!widget.isSecretary) ...[
const SizedBox(width: 6),
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0284C7),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () => _handleStatusChange(item, 'atendido'),
icon: const Icon(Icons.check_rounded, size: 13),
label: const Text('Finalizar', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
],
],
);
}
if (item.isAtendido) {
return Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)),
SizedBox(width: 4),
Text(
'Atendido',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF64748B)),
),
],
),
);
}
// no_presente
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isSecretary)
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(uniformRadius)),
visualDensity: VisualDensity.compact,
),
onPressed: () => _handleStatusChange(item, 'en_espera'),
icon: const Icon(Icons.how_to_reg_rounded, size: 13),
label: const Text('Llegó', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700)),
),
),
],
);
}
Widget _buildBottomBar() {
return 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.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 Spacer(),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text(
'Cerrar',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
],
),
);
}
}
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../auth/data/services/auth_service.dart';
import '../store/dashboard_store.dart';
import '../widgets/dashboard_app_bar.dart';
import '../widgets/dashboard_metrics_cards.dart';
import '../widgets/quick_actions_dropdown.dart';
import '../widgets/recent_patients_card.dart';
import '../widgets/today_appointments_card.dart';
import '../../../patient_search/presentation/views/patient_search_modal_view.dart';
import '../../../patient/presentation/views/patient_modal_view.dart';
import '../../../config/presentation/views/config_modal_view.dart';
import 'appointments_modal_view.dart';
import '../../../../core/widgets/medical_modal.dart';
class DashboardView extends StatefulWidget {
final DashboardStore? store;
const DashboardView({super.key, this.store});
@override
State<DashboardView> createState() => _DashboardViewState();
}
class _DashboardViewState extends State<DashboardView> {
late final DashboardStore _store;
bool _isSecretary = false;
@override
void initState() {
super.initState();
_store = widget.store ?? DashboardStore();
_store.loadDashboardData();
_loadUserRole();
}
Future<void> _loadUserRole() async {
final session = await AuthService().loadSavedSession();
if (mounted && session != null) {
setState(() {
_isSecretary = session.isSecretary;
});
}
}
void _handleLogout() {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.logout_rounded, color: AppColors.error),
SizedBox(width: 10),
Text('Cerrar Sesión', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
content: const Text('¿Está seguro de que desea salir del sistema Hidalyx Med?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.of(dialogContext).pop();
await AuthService().logout();
if (mounted) {
context.go('/login');
}
},
child: const Text('Salir'),
),
],
),
);
}
void _handleQuickAction(QuickActionType action) {
switch (action) {
case QuickActionType.nuevoPaciente:
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
isSecretary: _isSecretary,
onSaved: (_) {
_store.loadDashboardData();
},
),
);
break;
case QuickActionType.buscarPaciente:
showMedicalModal(
context: context,
builder: (_) => PatientSearchModalView(isSecretary: _isSecretary),
);
break;
case QuickActionType.configuracion:
showMedicalModal(
context: context,
builder: (_) => ConfigModalView(
onSaved: (updatedConfig) {
_store.doctorInfo = updatedConfig;
},
),
);
break;
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: DashboardAppBar(
doctorName: _store.doctorInfo.nombreDoctor,
specialty: _store.doctorInfo.especialidad,
isSecretary: _isSecretary,
onLogoutPressed: _handleLogout,
onQuickActionSelected: _handleQuickAction,
),
body: _store.isLoading
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado de Bienvenida
Row(
children: [
Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isSecretary ? 'Recepción de Pacientes' : 'Panel Central - Consultorio',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
_isSecretary
? 'Módulo de recepción, registro y sala de espera.'
: 'Bienvenido, ${_store.doctorInfo.nombreDoctor}. Sistema clínico listo.',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
),
// Indicador en Vivo de Sala de Espera
if (_store.enEsperaCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF86EFAC)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.chair_rounded,
size: 18,
color: Color(0xFF15803D),
),
const SizedBox(width: 8),
Text(
'${_store.enEsperaCount} en Sala de Espera',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
],
),
const SizedBox(height: 24),
// Mensaje de Error / Reintento si aplica
if (_store.errorMessage != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline_rounded, color: AppColors.warning),
const SizedBox(width: 12),
Expanded(
child: Text(
'Aviso de sincronización: ${_store.errorMessage}',
style: const TextStyle(color: AppColors.textPrimary, fontSize: 13),
),
),
TextButton(
onPressed: () => _store.loadDashboardData(),
child: const Text('Reintentar'),
),
],
),
),
const SizedBox(height: 16),
],
// Las 4 Tarjetas de Métricas Solicitadas
DashboardMetricsCards(metrics: _store.metrics),
const SizedBox(height: 24),
// Paneles de Detalle: Pacientes Nuevos y Citas para Hoy
LayoutBuilder(
builder: (context, constraints) {
final recentCard = RecentPatientsCard(
patients: _store.recentPatients,
);
final appointmentsCard = TodayAppointmentsCard(
appointments: _store.todayAppointments,
isSecretary: _isSecretary,
onStatusChange: (patient, newStatus) async {
await _store.changeWaitingStatus(patient.id, newStatus);
},
onSchedulePressed: () {
showMedicalModal(
context: context,
builder: (_) => AppointmentsModalView(
isSecretary: _isSecretary,
onStatusChanged: (patientId, newStatus) {
_store.changeWaitingStatus(patientId, newStatus);
},
),
);
},
onPatientTap: (patient) {
if (patient.id > 0) {
showMedicalModal(
context: context,
builder: (_) => PatientModalView(
patientId: patient.id,
isSecretary: _isSecretary,
onSaved: (_) => _store.loadDashboardData(),
),
);
}
},
);
if (constraints.maxWidth >= 1000) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: recentCard),
const SizedBox(width: 16),
Expanded(child: appointmentsCard),
],
),
);
}
return Column(
children: [
recentCard,
const SizedBox(height: 16),
appointmentsCard,
],
);
},
),
],
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../core/theme/app_colors.dart';
import '../widgets/quick_actions_dropdown.dart';
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
final String doctorName;
final String specialty;
final bool isSecretary;
final VoidCallback? onLogoutPressed;
final ValueChanged<QuickActionType>? onQuickActionSelected;
const DashboardAppBar({
super.key,
required this.doctorName,
required this.specialty,
this.isSecretary = false,
this.onLogoutPressed,
this.onQuickActionSelected,
});
@override
Size get preferredSize => const Size.fromHeight(70);
@override
Widget build(BuildContext context) {
return Container(
height: preferredSize.height,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(color: AppColors.border, width: 1),
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
// 1. Parte Izquierda: Nombre del Doctor y Especialidad (Flexible)
Expanded(
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.medical_services_rounded,
color: AppColors.primary,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
doctorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: AppColors.textPrimary,
),
),
Row(
children: [
Flexible(
child: Text(
specialty,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
if (isSecretary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'RECEPCIÓN',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: Color(0xFF00A65A),
letterSpacing: 0.4,
),
),
),
],
],
),
],
),
),
],
),
),
// 2. Parte Central: Logo de Hidalyx
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SvgPicture.asset(
'assets/images/hidalyx_logo_blue.svg',
height: 32,
fit: BoxFit.contain,
),
),
// 3. Parte Derecha: Dropdown de Acciones Rápidas + Botón Cerrar Sesión
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
QuickActionsDropdown(
onActionSelected: onQuickActionSelected,
isSecretary: isSecretary,
),
const SizedBox(width: 10),
Tooltip(
message: 'Cerrar sesión',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onLogoutPressed,
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.logout_rounded,
color: AppColors.textSecondary,
size: 18,
),
),
),
),
),
],
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/dashboard_metrics_model.dart';
class DashboardMetricsCards extends StatelessWidget {
final DashboardMetricsModel metrics;
const DashboardMetricsCards({super.key, required this.metrics});
String _formatNumber(int n) {
return n.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
@override
Widget build(BuildContext context) {
final cards = [
// 1. Total de Nuevos Pacientes
_buildMetricCard(
title: 'Total Nuevos Pacientes',
value: _formatNumber(metrics.nuevosPacientes),
subtitle: 'Registrados en la plataforma',
icon: Icons.person_add_alt_1_rounded,
iconColor: AppColors.primary,
bgColor: AppColors.primaryContainer,
),
// 2. Total de Consultados Hoy
_buildMetricCard(
title: 'Total Consultados Hoy',
value: _formatNumber(metrics.consultadosHoy),
subtitle: 'Atenciones médicas realizadas',
icon: Icons.assignment_turned_in_rounded,
iconColor: AppColors.teal,
bgColor: AppColors.teal.withValues(alpha: 0.12),
),
// 3. Total de Citas para Hoy
_buildMetricCard(
title: 'Total Citas para Hoy',
value: _formatNumber(metrics.citasHoy),
subtitle: 'Pacientes agendados',
icon: Icons.event_available_rounded,
iconColor: AppColors.warning,
bgColor: AppColors.warning.withValues(alpha: 0.12),
),
// 4. Total de Pacientes por Sexo
_buildGenderCard(),
];
return LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
// Distribución equitativa en 4 columnas (pantallas amplias)
if (constraints.maxWidth >= 1000) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[0]),
const SizedBox(width: spacing),
Expanded(child: cards[1]),
const SizedBox(width: spacing),
Expanded(child: cards[2]),
const SizedBox(width: spacing),
Expanded(child: cards[3]),
],
),
);
}
// Distribución equitativa en 2 columnas (pantallas medianas)
if (constraints.maxWidth >= 550) {
return Column(
children: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[0]),
const SizedBox(width: spacing),
Expanded(child: cards[1]),
],
),
),
const SizedBox(height: spacing),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: cards[2]),
const SizedBox(width: spacing),
Expanded(child: cards[3]),
],
),
),
],
);
}
// 1 columna ocupando 100% del ancho (pantallas móviles / compactas)
return Column(
children: [
cards[0],
const SizedBox(height: spacing),
cards[1],
const SizedBox(height: spacing),
cards[2],
const SizedBox(height: spacing),
cards[3],
],
);
},
);
}
Widget _buildMetricCard({
required String title,
String? value,
Widget? valueWidget,
required String subtitle,
required IconData icon,
required Color iconColor,
required Color bgColor,
}) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
valueWidget ??
Text(
value ?? '',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
);
}
Widget _buildGenderCard() {
final total = metrics.pacientesMasculinos + metrics.pacientesFemeninos;
final mascPct = total > 0 ? ((metrics.pacientesMasculinos / total) * 100).toInt() : 81;
final femPct = total > 0 ? ((metrics.pacientesFemeninos / total) * 100).toInt() : 19;
return _buildMetricCard(
title: 'Pacientes por Sexo',
valueWidget: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_formatNumber(metrics.pacientesMasculinos),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
' M',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const SizedBox(width: 8),
const Text(
'/',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.border,
),
),
const SizedBox(width: 8),
Text(
_formatNumber(metrics.pacientesFemeninos),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFFEC4899),
),
),
const Text(
' F',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFFEC4899),
),
),
],
),
),
subtitle: '$mascPct% Masc · $femPct% Fem',
icon: Icons.people_alt_rounded,
iconColor: AppColors.secondary,
bgColor: AppColors.secondary.withValues(alpha: 0.12),
);
}
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
enum QuickActionType {
nuevoPaciente,
buscarPaciente,
configuracion,
}
typedef QuickActionBar = QuickActionsDropdown;
class QuickActionsDropdown extends StatelessWidget {
final ValueChanged<QuickActionType>? onActionSelected;
final bool isSecretary;
const QuickActionsDropdown({
super.key,
this.onActionSelected,
this.isSecretary = false,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 1. Botón Buscar Paciente (Acción Secundaria / Buscador Rápido)
Tooltip(
message: 'Buscar paciente por nombre, cédula o teléfono',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.buscarPaciente),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_rounded,
color: AppColors.textSecondary,
size: 17,
),
SizedBox(width: 7),
Text(
'Buscar Paciente',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
),
const SizedBox(width: 8),
// 2. Botón Nuevo Paciente (Acción Primaria Destacada de 1 clic)
Tooltip(
message: 'Registrar nuevo paciente',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.nuevoPaciente),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.22),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_add_rounded,
color: Colors.white,
size: 17,
),
SizedBox(width: 7),
Text(
'Nuevo Paciente',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
// 3. Botón Configuración (Solo Doctor / Administrador, oculto para Secretaria)
if (!isSecretary) ...[
const SizedBox(width: 8),
Tooltip(
message: 'Configuración del Consultorio',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onActionSelected?.call(QuickActionType.configuracion),
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.settings_outlined,
color: AppColors.textSecondary,
size: 18,
),
),
),
),
),
],
],
);
}
}
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/patient_summary_model.dart';
class RecentPatientsCard extends StatelessWidget {
final List<PatientSummaryModel> patients;
const RecentPatientsCard({
super.key,
required this.patients,
});
String _getInitials(String name, String lastName) {
final n = name.trim().isNotEmpty ? name.trim()[0] : '';
final l = lastName.trim().isNotEmpty ? lastName.trim()[0] : '';
final res = '$n$l'.toUpperCase();
return res.isNotEmpty ? res : 'P';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.person_add_alt_1_rounded,
color: AppColors.primary,
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pacientes Nuevos',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Últimos registros ingresados al sistema',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 18),
const Divider(color: AppColors.divider, height: 1),
const SizedBox(height: 14),
// Contenido con Scroll Interno delimitado
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 330),
child: Scrollbar(
child: SingleChildScrollView(
child: patients.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
children: [
Icon(Icons.people_outline_rounded, size: 40, color: AppColors.textMuted),
SizedBox(height: 8),
Text(
'No se encontraron pacientes recientes',
style: TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
],
),
),
)
: Padding(
padding: const EdgeInsets.only(right: 6),
child: Column(
children: [
for (var i = 0; i < patients.length; i++) ...[
if (i > 0) const Divider(color: AppColors.divider, height: 16),
Row(
children: [
// Avatar con iniciales
CircleAvatar(
radius: 19,
backgroundColor: AppColors.primaryContainer,
child: Text(
_getInitials(patients[i].nombre, patients[i].apellido),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 14),
// Nombre y Detalles
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
patients[i].fullName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (patients[i].telefono.isNotEmpty)
Text(
patients[i].telefono,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
Text(
patients[i].cedula.isNotEmpty
? patients[i].cedula
: (patients[i].seguro.isNotEmpty ? patients[i].seguro : 'Particular'),
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
),
const SizedBox(width: 10),
// Badge de Fecha de Registro
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_today_rounded,
size: 11,
color: AppColors.textSecondary,
),
const SizedBox(width: 5),
Text(
patients[i].fecReg.isNotEmpty
? patients[i].fecReg.split(' ').first
: 'Reciente',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
],
],
),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../data/models/patient_summary_model.dart';
class TodayAppointmentsCard extends StatelessWidget {
final List<PatientSummaryModel> appointments;
final VoidCallback? onSchedulePressed;
final ValueChanged<PatientSummaryModel>? onPatientTap;
final bool isSecretary;
final Future<void> Function(PatientSummaryModel patient, String newStatus)? onStatusChange;
const TodayAppointmentsCard({
super.key,
required this.appointments,
this.onSchedulePressed,
this.onPatientTap,
this.isSecretary = false,
this.onStatusChange,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.event_available_rounded,
color: AppColors.warning,
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Citas para Hoy',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Agenda y turnos de consulta médica',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (onSchedulePressed != null)
TextButton(
onPressed: onSchedulePressed,
child: const Text(
'Ver todos',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
],
),
const SizedBox(height: 18),
const Divider(color: AppColors.divider, height: 1),
const SizedBox(height: 14),
// Contenido de Citas con Scroll Interno delimitado
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 330),
child: Scrollbar(
child: SingleChildScrollView(
child: appointments.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_note_rounded,
size: 40,
color: AppColors.textMuted.withValues(alpha: 0.6),
),
const SizedBox(height: 8),
const Text(
'No hay citas registradas para el día de hoy',
style: TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 4),
const Text(
'Los nuevos turnos agendados aparecerán aquí',
style: TextStyle(color: AppColors.textMuted, fontSize: 11),
),
],
),
),
)
: Padding(
padding: const EdgeInsets.only(right: 6),
child: Column(
children: [
for (var i = 0; i < appointments.length; i++) ...[
if (i > 0) const Divider(color: AppColors.divider, height: 16),
Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onPatientTap != null && appointments[i].id > 0
? () => onPatientTap!(appointments[i])
: null,
borderRadius: BorderRadius.circular(10),
hoverColor: AppColors.primary.withValues(alpha: 0.05),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
child: Row(
children: [
// Icono de Cita
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: AppColors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.access_time_rounded,
color: AppColors.teal,
size: 20,
),
),
const SizedBox(width: 14),
// Paciente y Motivo de Consulta
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appointments[i].fullName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (appointments[i].telefono.isNotEmpty)
Text(
appointments[i].telefono,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
Text(
appointments[i].motCons.isNotEmpty
? appointments[i].motCons.replaceAll('\n', ' ').trim()
: 'Consulta de rutina',
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
),
const SizedBox(width: 10),
// Estado de Sala de Espera y Acciones Rápidas
_buildStatusAndAction(context, appointments[i]),
],
),
),
),
),
],
],
),
),
),
),
),
],
),
);
}
Widget _buildStatusAndAction(BuildContext context, PatientSummaryModel patient) {
const double uniformHeight = 28.0;
const double uniformRadius = 6.0;
if (patient.isEnEspera) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Badge En Espera
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF86EFAC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_top_rounded, size: 12, color: Color(0xFF15803D)),
SizedBox(width: 4),
Text(
'En Espera',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF15803D),
),
),
],
),
),
// Botón Doctor: Llamar a Consulta
if (!isSecretary) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Llamar a consulta médica ahora',
child: SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () async {
if (onStatusChange != null) {
await onStatusChange!(patient, 'en_consulta');
}
if (onPatientTap != null) {
onPatientTap!(patient);
}
},
icon: const Icon(Icons.arrow_forward_rounded, size: 12),
label: const Text(
'Atender',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
),
],
// Botón Secretaria: Revertir si fue error
if (isSecretary && onStatusChange != null) ...[
const SizedBox(width: 4),
Tooltip(
message: 'Quitar de la sala de espera',
child: InkWell(
onTap: () => onStatusChange!(patient, 'no_presente'),
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
width: uniformHeight,
alignment: Alignment.center,
child: const Icon(Icons.close_rounded, size: 15, color: AppColors.textMuted),
),
),
),
],
],
);
}
if (patient.isEnConsulta) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onPatientTap != null ? () => onPatientTap!(patient) : null,
borderRadius: BorderRadius.circular(uniformRadius),
child: Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFE0F2FE),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFF7DD3FC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_services_rounded, size: 12, color: Color(0xFF0369A1)),
SizedBox(width: 4),
Text(
'En Consulta',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF0369A1),
),
),
],
),
),
),
if (!isSecretary && onStatusChange != null) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Finalizar consulta médica y marcar como Atendido',
child: SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0284C7),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () => onStatusChange!(patient, 'atendido'),
icon: const Icon(Icons.check_rounded, size: 13),
label: const Text(
'Finalizar',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
),
],
],
);
}
if (patient.isAtendido) {
return Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(uniformRadius),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_rounded, size: 12, color: Color(0xFF64748B)),
SizedBox(width: 4),
Text(
'Atendido',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
],
),
);
}
// Estado no_presente (No ha llegado aún)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSecretary && onStatusChange != null)
SizedBox(
height: uniformHeight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(uniformRadius),
),
visualDensity: VisualDensity.compact,
),
onPressed: () => onStatusChange!(patient, 'en_espera'),
icon: const Icon(Icons.how_to_reg_rounded, size: 13),
label: const Text(
'Llegó',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
)
else
Container(
height: uniformHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(uniformRadius),
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_month_rounded,
size: 11,
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
patient.fecProxCita.isNotEmpty
? patient.fecProxCita.split(' ').first
: 'Hoy',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,533 @@
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/insurance_auth_pdf_service.dart';
class InsuranceAuthModalView extends StatefulWidget {
final String patientName;
final String? initialDiagnosis;
final ConfigStore? configStore;
const InsuranceAuthModalView({
super.key,
required this.patientName,
this.initialDiagnosis,
this.configStore,
});
@override
State<InsuranceAuthModalView> createState() => _InsuranceAuthModalViewState();
}
class _InsuranceAuthModalViewState extends State<InsuranceAuthModalView> {
static const List<String> arsList = [
'Humano Seguros, S.A.',
'Seguros Universal, S. A.',
'Seguros Reservas, S.A.',
'La Monumental de Seguros, C por A',
'Palic / Primera ARS',
'Seguros Pepín, S. A.',
'La Colonial, S.A.',
'Banesco Seguros, S.A.',
'Seguros La Internacional, S. A.',
'Seguros Patria, S. A.',
'Angloamericana de Seguros, S. A.',
'General de Seguros, S.A.',
'Seguros Ademi, S.A.',
'ARS Senasa',
'ARS Simag',
'ARS Renacer',
'ARS GMA',
'ARS Yunen',
];
late final TextEditingController _patientController;
late final TextEditingController _arsController;
late final TextEditingController _surgeryController;
late final TextEditingController _diagnosisController;
late final TextEditingController _anesthesiaController;
late final TextEditingController _daysController;
late final ConfigStore _configStore;
bool _isPrinting = false;
String? _statusMessage;
bool _hasError = false;
@override
void initState() {
super.initState();
_patientController = TextEditingController(text: widget.patientName);
_arsController = TextEditingController(text: arsList.first);
_surgeryController = TextEditingController();
_diagnosisController = TextEditingController(text: widget.initialDiagnosis ?? '');
_anesthesiaController = TextEditingController(text: 'General');
_daysController = TextEditingController(text: '1');
_configStore = widget.configStore ?? ConfigStore();
_configStore.loadConfig();
}
@override
void dispose() {
_patientController.dispose();
_arsController.dispose();
_surgeryController.dispose();
_diagnosisController.dispose();
_anesthesiaController.dispose();
_daysController.dispose();
super.dispose();
}
Future<void> _handlePrint() async {
final ars = _arsController.text.trim();
final patient = _patientController.text.trim();
final surgery = _surgeryController.text.trim();
final diagnosis = _diagnosisController.text.trim();
if (ars.isEmpty) {
setState(() {
_statusMessage = 'Debe indicar o seleccionar la ARS destinataria.';
_hasError = true;
});
return;
}
if (patient.isEmpty) {
setState(() {
_statusMessage = 'Debe indicar el nombre del paciente.';
_hasError = true;
});
return;
}
if (surgery.isEmpty) {
setState(() {
_statusMessage = 'Debe indicar la cirugía o procedimiento a autorizar.';
_hasError = true;
});
return;
}
if (diagnosis.isEmpty) {
setState(() {
_statusMessage = 'Debe indicar el diagnóstico correspondiente.';
_hasError = true;
});
return;
}
setState(() {
_isPrinting = true;
_statusMessage = 'Generando solicitud de autorización de seguro...';
_hasError = false;
});
try {
await InsuranceAuthPdfService.printInsuranceAuth(
doctorName: _configStore.nombreDoctor,
specialty: _configStore.especialidad,
subspecialty: _configStore.descripEspecialidad,
generalContact: _configStore.descripGeneral,
arsName: ars,
patientName: patient,
surgeryText: surgery,
diagnosisText: diagnosis,
anesthesia: _anesthesiaController.text.trim(),
daysOfStay: _daysController.text.trim(),
);
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Autorización generada con éxito.';
_hasError = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Error al generar la autorización: $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: 'Autorización De Seguro (ARS)',
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.verified_user_rounded,
color: Color(0xFF00A65A),
size: 20,
),
),
subtitle: const Text(
'Formulario oficial de solicitud de cobertura quirúrgica para aseguradoras',
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 autorización de seguro'),
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 Autorización',
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
Container(
padding: const EdgeInsets.all(14),
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),
// Fila 1: Señores ARS y Paciente (Favor autorizar al Sr. a)
Row(
children: [
Expanded(
flex: 5,
child: Autocomplete<String>(
initialValue: TextEditingValue(text: _arsController.text),
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return arsList;
}
return arsList.where((ars) => ars
.toLowerCase()
.contains(textEditingValue.text.toLowerCase()));
},
onSelected: (selection) => _arsController.text = selection,
fieldViewBuilder: (context, controller, focusNode, onEditingComplete) {
controller.addListener(() {
_arsController.text = controller.text;
});
return TextField(
controller: controller,
focusNode: focusNode,
onEditingComplete: onEditingComplete,
decoration: InputDecoration(
labelText: 'Señores ARS *',
hintText: 'Seleccione o escriba la ARS',
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.security_rounded, size: 18, color: AppColors.primary),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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: 12.5, fontWeight: FontWeight.w600),
);
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: TextField(
controller: _patientController,
decoration: InputDecoration(
labelText: 'Favor autorizar al Sr. (a) *',
hintText: 'Nombre del paciente',
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.person_outline_rounded, size: 18, color: AppColors.primary),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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: 12.5, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 14),
// Campo: La cirugía de:
TextField(
controller: _surgeryController,
maxLines: 3,
style: const TextStyle(fontSize: 13, height: 1.4),
decoration: InputDecoration(
labelText: 'La cirugía de: *',
hintText: 'Especifique el procedimiento quirúrgico o intervención (ej. Ureterolitotomía endoscópica láser, Biopsia de próstata...)',
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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),
),
),
),
const SizedBox(height: 14),
// Campo: Diagnóstico:
TextField(
controller: _diagnosisController,
maxLines: 3,
style: const TextStyle(fontSize: 13, height: 1.4),
decoration: InputDecoration(
labelText: 'Diagnóstico: *',
hintText: 'Diagnóstico principal, patología o hallazgo clínico que justifica la cirugía...',
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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),
),
),
),
const SizedBox(height: 14),
// Fila 3: Bajo Anestesia y Días de ingreso
Row(
children: [
Expanded(
flex: 6,
child: TextField(
controller: _anesthesiaController,
decoration: InputDecoration(
labelText: 'Bajo Anestesia',
hintText: 'General, Raquídea, Sedación, Local...',
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.vaccines_outlined, size: 18, color: AppColors.primary),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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: 12.5),
),
),
const SizedBox(width: 12),
Expanded(
flex: 4,
child: TextField(
controller: _daysController,
decoration: InputDecoration(
labelText: 'Días de ingreso',
hintText: 'Ej. 1, 2, Ambulatorio',
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
prefixIcon: const Icon(Icons.hotel_outlined, size: 18, color: AppColors.primary),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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: 12.5),
),
),
],
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,383 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class InsuranceAuthPdfService {
/// Genera el documento PDF de Autorización de Seguro en formato Media Carta Horizontal
/// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical.
static Future<Uint8List> buildInsuranceAuthPdf({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String arsName,
required String patientName,
required String surgeryText,
required String diagnosisText,
String? anesthesia,
String? daysOfStay,
DateTime? date,
pw.Font? fontRegular,
pw.Font? fontBold,
pw.Font? fontTitle,
String? logoSvg,
}) async {
final pdf = pw.Document(title: 'Autorización de seguro - $patientName');
final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now());
// Formato exacto e idéntico a AlfaMedical (ReportLab):
// pagesize = (8.5*inch, 5.5*inch)
// leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18.
const pageFormat = PdfPageFormat(
8.5 * PdfPageFormat.inch,
5.5 * PdfPageFormat.inch,
marginLeft: 72,
marginRight: 72,
marginTop: 18,
marginBottom: 18,
);
// Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet
pw.Font regular = fontRegular ?? pw.Font.helvetica();
pw.Font bold = fontBold ?? pw.Font.helveticaBold();
pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic();
if (fontRegular == null || fontBold == null || fontTitle == null) {
try {
regular = await PdfGoogleFonts.robotoRegular();
bold = await PdfGoogleFonts.robotoBold();
titleFont = await PdfGoogleFonts.playfairDisplayItalic();
} catch (_) {
regular = pw.Font.helvetica();
bold = pw.Font.helveticaBold();
titleFont = pw.Font.timesBoldItalic();
}
}
String? svgContent = logoSvg;
if (svgContent == null) {
try {
svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg');
} catch (_) {
svgContent = null;
}
}
pdf.addPage(
pw.Page(
pageFormat: pageFormat,
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med
pw.Stack(
children: [
// Nombre y especialidad del doctor centrado
pw.Align(
alignment: pw.Alignment.center,
child: pw.Column(
children: [
pw.Text(
doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa',
style: pw.TextStyle(
font: titleFont,
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('1E293B'),
),
),
pw.SizedBox(height: 3),
if (specialty.isNotEmpty)
pw.Text(
specialty.toUpperCase(),
style: pw.TextStyle(
font: bold,
fontSize: 10.5,
letterSpacing: 0.5,
color: PdfColor.fromHex('0F172A'),
),
),
if (subspecialty.isNotEmpty) ...[
pw.SizedBox(height: 2),
pw.Text(
subspecialty.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 8.5,
color: PdfColor.fromHex('475569'),
),
),
],
pw.SizedBox(height: 3),
pw.Text(
'Fecha: $formattedDate',
style: pw.TextStyle(
font: regular,
fontSize: 9.5,
color: PdfColor.fromHex('334155'),
),
),
],
),
),
// Badge sutil de Hidalyx Med en la esquina superior derecha
pw.Positioned(
top: 0,
right: 0,
child: pw.Row(
mainAxisSize: pw.MainAxisSize.min,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (svgContent != null && svgContent.isNotEmpty) ...[
pw.Opacity(
opacity: 0.6,
child: pw.SvgImage(
svg: svgContent,
width: 7.5,
height: 7.5,
),
),
pw.SizedBox(width: 3),
],
pw.Text(
'hidalyx.com',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
],
),
),
],
),
pw.SizedBox(height: 6),
// Línea divisoria decorativa del membrete
pw.Container(
height: 1,
color: PdfColor.fromHex('CBD5E1'),
),
pw.SizedBox(height: 8),
// 2. Encabezado de la Orden Médica (Símbolo Rx)
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColor.fromHex('E2E8F0'),
borderRadius: pw.BorderRadius.circular(4),
),
child: pw.Text(
'Rx',
style: pw.TextStyle(
font: bold,
fontSize: 12,
color: PdfColor.fromHex('00A65A'),
),
),
),
pw.SizedBox(width: 8),
pw.Text(
'SOLICITUD DE AUTORIZACIÓN QUIRÚRGICA',
style: pw.TextStyle(
font: bold,
fontSize: 10,
letterSpacing: 0.5,
color: PdfColor.fromHex('475569'),
),
),
],
),
pw.SizedBox(height: 8),
// 3. Contenido Formal idéntico a AlfaMedical
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// Señores ARS
pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: 'Señores ARS: ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: arsName.isNotEmpty ? arsName : '____________________________________',
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
),
),
pw.SizedBox(height: 6),
// Favor autorizar al Sr. (a)
pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: 'Favor autorizar al Sr. (a): ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: patientName.isNotEmpty ? patientName : '___________________________',
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
),
),
pw.SizedBox(height: 6),
// La cirugía de:
pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: 'La cirugía de: ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: surgeryText.trim().isNotEmpty ? surgeryText.trim() : '___________________________',
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
),
),
pw.SizedBox(height: 6),
// Diagnóstico
pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: 'Diagnóstico: ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: diagnosisText.trim().isNotEmpty ? diagnosisText.trim() : '___________________________',
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
),
),
pw.SizedBox(height: 6),
// Bajo Anestesia y Días de ingreso
if ((anesthesia != null && anesthesia.trim().isNotEmpty) ||
(daysOfStay != null && daysOfStay.trim().isNotEmpty)) ...[
pw.RichText(
text: pw.TextSpan(
children: [
if (anesthesia != null && anesthesia.trim().isNotEmpty) ...[
pw.TextSpan(
text: 'Bajo Anestesia: ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: anesthesia.trim(),
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
if (daysOfStay != null && daysOfStay.trim().isNotEmpty) ...[
pw.TextSpan(
text: ' Con ',
style: pw.TextStyle(font: bold, fontSize: 11, color: PdfColor.fromHex('1E293B')),
),
pw.TextSpan(
text: '${daysOfStay.trim()} Días de ingreso',
style: pw.TextStyle(font: regular, fontSize: 11, color: PdfColor.fromHex('0F172A')),
),
],
],
),
),
],
],
),
),
pw.SizedBox(height: 6),
// 4. Pie de página con Contacto del Consultorio
if (generalContact.isNotEmpty) ...[
pw.Container(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
generalContact.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 6.8,
color: PdfColor.fromHex('64748B'),
lineSpacing: 1.5,
),
),
),
],
pw.SizedBox(height: 3),
// Confirmación institucional
pw.Center(
child: pw.Text(
'Documento médico emitido electrónicamente',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
),
],
);
},
),
);
return pdf.save();
}
/// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF.
static Future<void> printInsuranceAuth({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String arsName,
required String patientName,
required String surgeryText,
required String diagnosisText,
String? anesthesia,
String? daysOfStay,
DateTime? date,
}) async {
final pdfBytes = await buildInsuranceAuthPdf(
doctorName: doctorName,
specialty: specialty,
subspecialty: subspecialty,
generalContact: generalContact,
arsName: arsName,
patientName: patientName,
surgeryText: surgeryText,
diagnosisText: diagnosisText,
anesthesia: anesthesia,
daysOfStay: daysOfStay,
date: date,
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdfBytes,
name: 'Autorizacion_Seguro_${patientName.replaceAll(' ', '_')}',
);
}
}
@@ -0,0 +1,175 @@
class PatientModel {
final int? id;
final String nombre;
final String apellido;
final String telefono;
final String direccion;
final String cedula;
final String ocupacion;
final String edad;
final String fecUltcon;
final String fecReg;
final String sexo;
final String seguro;
final String estCivil;
final String motCons;
final String hisEnfAct;
final String datPadre;
final String datEsp;
final String datHijos;
final String datOtrosnp;
final String datAlerg;
final String datTran;
final String datOtrosp;
final String metDiagnRad;
final String datLab;
final String diagnDesc;
final String diagnDef;
final String fecProxcita;
final String tratamiento;
final String datHer;
final String datMad;
final String datNi;
final String datAdo;
final String datAdu;
final String datAc;
final String datCir;
final String datJuv;
final String estadoEspera;
const PatientModel({
this.id,
this.nombre = '',
this.apellido = '',
this.telefono = '',
this.direccion = '',
this.cedula = '',
this.ocupacion = '',
this.edad = '',
this.fecUltcon = '',
this.fecReg = '',
this.sexo = 'Masculino',
this.seguro = 'Sí',
this.estCivil = 'Soltero',
this.motCons = '',
this.hisEnfAct = '',
this.datPadre = '',
this.datEsp = '',
this.datHijos = '',
this.datOtrosnp = '',
this.datAlerg = '',
this.datTran = '',
this.datOtrosp = '',
this.metDiagnRad = '',
this.datLab = '',
this.diagnDesc = '',
this.diagnDef = '',
this.fecProxcita = '',
this.tratamiento = '',
this.datHer = '',
this.datMad = '',
this.datNi = '',
this.datAdo = '',
this.datAdu = '',
this.datAc = '',
this.datCir = '',
this.datJuv = '',
this.estadoEspera = 'no_presente',
});
String get fullName => '$nombre $apellido'.trim();
factory PatientModel.fromJson(Map<String, dynamic> json) {
int? parsedId;
if (json['id'] is int) {
parsedId = json['id'] as int;
} else if (json['id'] != null) {
parsedId = int.tryParse(json['id'].toString());
}
return PatientModel(
id: parsedId,
nombre: json['nombre']?.toString().trim() ?? '',
apellido: json['apellido']?.toString().trim() ?? '',
telefono: json['telefono']?.toString().trim() ?? '',
direccion: json['direccion']?.toString().trim() ?? '',
cedula: json['cedula']?.toString().trim() ?? '',
ocupacion: json['ocupacion']?.toString().trim() ?? '',
edad: json['edad']?.toString().trim() ?? '',
fecUltcon: json['fec_ultcon']?.toString().trim() ?? '',
fecReg: json['fec_reg']?.toString().trim() ?? '',
sexo: json['sexo']?.toString().trim() ?? 'Masculino',
seguro: json['seguro']?.toString().trim() ?? 'Sí',
estCivil: json['est_civil']?.toString().trim() ?? 'Soltero',
motCons: json['mot_cons']?.toString().trim() ?? '',
hisEnfAct: json['his_enf_act']?.toString().trim() ?? '',
datPadre: json['dat_padre']?.toString().trim() ?? '',
datEsp: json['dat_esp']?.toString().trim() ?? '',
datHijos: json['dat_hijos']?.toString().trim() ?? '',
datOtrosnp: json['dat_otrosnp']?.toString().trim() ?? '',
datAlerg: json['dat_alerg']?.toString().trim() ?? '',
datTran: json['dat_tran']?.toString().trim() ?? '',
datOtrosp: json['dat_otrosp']?.toString().trim() ?? '',
metDiagnRad: json['met_diagn_rad']?.toString().trim() ?? '',
datLab: json['dat_lab']?.toString().trim() ?? '',
diagnDesc: json['diagn_desc']?.toString().trim() ?? '',
diagnDef: json['diagn_def']?.toString().trim() ?? '',
fecProxcita: json['fec_proxcita']?.toString().trim() ?? '',
tratamiento: json['tratamiento']?.toString().trim() ?? '',
datHer: json['dat_her']?.toString().trim() ?? '',
datMad: json['dat_mad']?.toString().trim() ?? '',
datNi: json['dat_ni']?.toString().trim() ?? '',
datAdo: json['dat_ado']?.toString().trim() ?? '',
datAdu: json['dat_adu']?.toString().trim() ?? '',
datAc: json['dat_ac']?.toString().trim() ?? '',
datCir: json['dat_cir']?.toString().trim() ?? '',
datJuv: json['dat_juv']?.toString().trim() ?? '',
estadoEspera: json['estado_espera']?.toString().trim() ?? 'no_presente',
);
}
Map<String, dynamic> toJson({bool includeId = false}) {
final data = <String, dynamic>{
'nombre': nombre,
'apellido': apellido,
'telefono': telefono,
'direccion': direccion,
'cedula': cedula,
'ocupacion': ocupacion,
'edad': edad,
'fec_ultcon': fecUltcon,
'fec_reg': fecReg,
'sexo': sexo,
'seguro': seguro,
'est_civil': estCivil,
'mot_cons': motCons,
'his_enf_act': hisEnfAct,
'dat_padre': datPadre,
'dat_esp': datEsp,
'dat_hijos': datHijos,
'dat_otrosnp': datOtrosnp,
'dat_alerg': datAlerg,
'dat_tran': datTran,
'dat_otrosp': datOtrosp,
'met_diagn_rad': metDiagnRad,
'dat_lab': datLab,
'diagn_desc': diagnDesc,
'diagn_def': diagnDef,
'fec_proxcita': fecProxcita,
'tratamiento': tratamiento,
'dat_her': datHer,
'dat_mad': datMad,
'dat_ni': datNi,
'dat_ado': datAdo,
'dat_adu': datAdu,
'dat_ac': datAc,
'dat_cir': datCir,
'dat_juv': datJuv,
'estado_espera': estadoEspera,
};
if (includeId && id != null) {
data['id'] = id;
}
return data;
}
}
@@ -0,0 +1,64 @@
import 'package:dio/dio.dart';
import '../../../../core/network/api_client.dart';
import '../models/patient_model.dart';
class PatientService {
final ApiClient apiClient;
PatientService({ApiClient? client}) : apiClient = client ?? ApiClient();
Future<PatientModel> getPatientById(int id) async {
try {
final response = await apiClient.dio.get('/items/pacientes/$id');
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>;
return PatientModel.fromJson(data);
}
throw Exception('No se encontró el paciente con ID $id');
} on DioException catch (e) {
final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message;
throw Exception('Error al obtener datos del paciente: $serverMessage');
} catch (e) {
throw Exception('Error al obtener datos del paciente: $e');
}
}
Future<PatientModel> createPatient(PatientModel model) async {
try {
final payload = model.toJson(includeId: false);
final response = await apiClient.dio.post(
'/items/pacientes',
data: payload,
);
if ((response.statusCode == 200 || response.statusCode == 201) && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>;
return PatientModel.fromJson(data);
}
throw Exception('Error en el servidor al registrar el paciente');
} on DioException catch (e) {
final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message;
throw Exception('Error al registrar paciente: $serverMessage');
} catch (e) {
throw Exception('Error al registrar paciente: $e');
}
}
Future<PatientModel> updatePatient(int id, Map<String, dynamic> updates) async {
try {
final response = await apiClient.dio.patch(
'/items/pacientes/$id',
data: updates,
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>;
return PatientModel.fromJson(data);
}
throw Exception('Error en el servidor al actualizar el paciente');
} on DioException catch (e) {
final serverMessage = e.response?.data?['errors']?[0]?['message'] ?? e.message;
throw Exception('Error al actualizar paciente: $serverMessage');
} catch (e) {
throw Exception('Error al actualizar paciente: $e');
}
}
}
@@ -0,0 +1,394 @@
import 'package:intl/intl.dart';
import 'package:mobx/mobx.dart';
import '../../data/models/patient_model.dart';
import '../../data/services/patient_service.dart';
part 'patient_store.g.dart';
// ignore: library_private_types_in_public_api
class PatientStore = _PatientStoreBase with _$PatientStore;
abstract class _PatientStoreBase with Store {
final PatientService service;
_PatientStoreBase({PatientService? patientService})
: service = patientService ?? PatientService();
@observable
int? id;
@observable
bool isLoading = false;
@observable
bool isSaving = false;
@observable
String? errorMessage;
@observable
String? successMessage;
// Campos Demográficos Superiores
@observable
String fecReg = '';
@observable
String cedula = '';
@observable
String nombre = '';
@observable
String apellido = '';
@observable
String direccion = '';
@observable
String telefono = '';
@observable
String ocupacion = '';
@observable
String edad = '';
@observable
String sexo = 'Masculino';
@observable
String seguro = 'Sí';
@observable
String estCivil = 'Soltero';
// Pestañas Clínicas
@observable
String motCons = '';
@observable
String fecUltcon = '';
@observable
String hisEnfAct = '';
// Antecedentes Personales Patológicos
@observable
String datNi = 'Ninguna';
@observable
String datAdo = 'Ninguna';
@observable
String datAdu = 'Ninguna';
@observable
String datAlerg = 'Ninguna';
@observable
String datTran = 'ninguna';
@observable
String datCir = 'ninguna';
@observable
String datAc = 'ninguno';
@observable
String datOtrosp = 'ninguno';
// Antecedentes No Patológicos
@observable
String datMad = 'Viva y sana';
@observable
String datEsp = 'Viva y sana';
@observable
String datPadre = 'Vivo y sano';
@observable
String datHijos = 'Vivos y sanos';
@observable
String datHer = 'Vivos y sanos';
@observable
String datOtrosnp = 'Ningunos';
// Diagnósticos
@observable
String metDiagnRad = '';
@observable
String diagnDesc = '';
@observable
String diagnDef = '';
// Laboratorios y Tratamiento
@observable
String datLab = '';
@observable
String tratamiento = '';
// Próxima Cita
@observable
String fecProxcita = '';
// Estado de Sala de Espera
@observable
String estadoEspera = 'no_presente';
@computed
bool get isEditing => id != null;
@computed
bool get isValid =>
nombre.trim().isNotEmpty &&
apellido.trim().isNotEmpty &&
sexo.trim().isNotEmpty &&
seguro.trim().isNotEmpty;
@computed
int? get diasParaProximaCita {
if (fecProxcita.isEmpty) return null;
try {
DateTime? target;
if (fecProxcita.contains('-')) {
target = DateTime.tryParse(fecProxcita);
} else if (fecProxcita.contains('/')) {
final parts = fecProxcita.split('/');
if (parts.length == 3) {
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
target = DateTime(year, month, day);
}
}
if (target == null) return null;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final cleanTarget = DateTime(target.year, target.month, target.day);
return cleanTarget.difference(today).inDays;
} catch (_) {
return null;
}
}
@action
void initForCreate() {
id = null;
errorMessage = null;
successMessage = null;
isLoading = false;
isSaving = false;
final now = DateTime.now();
fecReg = DateFormat('dd/MM/yyyy').format(now);
fecUltcon = DateFormat('dd/MM/yyyy').format(now);
cedula = '';
nombre = '';
apellido = '';
direccion = '';
telefono = '';
ocupacion = '';
edad = '';
sexo = 'Masculino';
seguro = 'Sí';
estCivil = 'Soltero';
motCons = '';
hisEnfAct = '';
datNi = 'Ninguna';
datAdo = 'Ninguna';
datAdu = 'Ninguna';
datAlerg = 'Ninguna';
datTran = 'ninguna';
datCir = 'ninguna';
datAc = 'ninguno';
datOtrosp = 'ninguno';
datMad = 'Viva y sana';
datEsp = 'Viva y sana';
datPadre = 'Vivo y sano';
datHijos = 'Vivos y sanos';
datHer = 'Vivos y sanos';
datOtrosnp = 'Ningunos';
metDiagnRad = '';
diagnDesc = '';
diagnDef = '';
datLab = '';
tratamiento = '';
fecProxcita = '';
estadoEspera = 'no_presente';
}
@action
void populateFromModel(PatientModel p) {
id = p.id;
fecReg = p.fecReg;
fecUltcon = p.fecUltcon;
cedula = p.cedula;
nombre = p.nombre;
apellido = p.apellido;
direccion = p.direccion;
telefono = p.telefono;
ocupacion = p.ocupacion;
edad = p.edad;
sexo = p.sexo.isNotEmpty ? p.sexo : 'Masculino';
seguro = p.seguro.isNotEmpty ? p.seguro : 'Sí';
estCivil = p.estCivil.isNotEmpty ? p.estCivil : 'Soltero';
motCons = p.motCons;
hisEnfAct = p.hisEnfAct;
datNi = p.datNi.isNotEmpty ? p.datNi : 'Ninguna';
datAdo = p.datAdo.isNotEmpty ? p.datAdo : 'Ninguna';
datAdu = p.datAdu.isNotEmpty ? p.datAdu : 'Ninguna';
datAlerg = p.datAlerg.isNotEmpty ? p.datAlerg : 'Ninguna';
datTran = p.datTran.isNotEmpty ? p.datTran : 'ninguna';
datCir = p.datCir.isNotEmpty ? p.datCir : 'ninguna';
datAc = p.datAc.isNotEmpty ? p.datAc : 'ninguno';
datOtrosp = p.datOtrosp.isNotEmpty ? p.datOtrosp : 'ninguno';
datMad = p.datMad.isNotEmpty ? p.datMad : 'Viva y sana';
datEsp = p.datEsp.isNotEmpty ? p.datEsp : 'Viva y sana';
datPadre = p.datPadre.isNotEmpty ? p.datPadre : 'Vivo y sano';
datHijos = p.datHijos.isNotEmpty ? p.datHijos : 'Vivos y sanos';
datHer = p.datHer.isNotEmpty ? p.datHer : 'Vivos y sanos';
datOtrosnp = p.datOtrosnp.isNotEmpty ? p.datOtrosnp : 'Ningunos';
metDiagnRad = p.metDiagnRad;
diagnDesc = p.diagnDesc;
diagnDef = p.diagnDef;
datLab = p.datLab;
tratamiento = p.tratamiento;
fecProxcita = p.fecProxcita;
estadoEspera = p.estadoEspera.isNotEmpty ? p.estadoEspera : 'no_presente';
}
@action
Future<void> loadPatient(int patientId) async {
isLoading = true;
errorMessage = null;
try {
final p = await service.getPatientById(patientId);
runInAction(() {
populateFromModel(p);
isLoading = false;
});
} catch (e) {
runInAction(() {
errorMessage = e.toString().replaceAll('Exception: ', '');
isLoading = false;
});
}
}
@action
void appendCurrentDateToConsultas() {
final dateStr = DateFormat('dd/MM/yyyy').format(DateTime.now());
if (motCons.isEmpty) {
motCons = '$dateStr: ';
} else {
motCons = '$motCons\n$dateStr: ';
}
}
@action
void appendCurrentDateToHistorial() {
final dateStr = DateFormat('dd/MM/yyyy').format(DateTime.now());
if (hisEnfAct.isEmpty) {
hisEnfAct = '$dateStr: ';
} else {
hisEnfAct = '$hisEnfAct\n$dateStr: ';
}
}
PatientModel toModel() {
return PatientModel(
id: id,
nombre: nombre.trim(),
apellido: apellido.trim(),
telefono: telefono.trim(),
direccion: direccion.trim(),
cedula: cedula.trim(),
ocupacion: ocupacion.trim(),
edad: edad.trim(),
fecUltcon: fecUltcon.trim(),
fecReg: fecReg.trim(),
sexo: sexo.trim(),
seguro: seguro.trim(),
estCivil: estCivil.trim(),
motCons: motCons,
hisEnfAct: hisEnfAct,
datPadre: datPadre,
datEsp: datEsp,
datHijos: datHijos,
datOtrosnp: datOtrosnp,
datAlerg: datAlerg,
datTran: datTran,
datOtrosp: datOtrosp,
metDiagnRad: metDiagnRad,
datLab: datLab,
diagnDesc: diagnDesc,
diagnDef: diagnDef,
fecProxcita: fecProxcita.trim(),
tratamiento: tratamiento,
datHer: datHer,
datMad: datMad,
datNi: datNi,
datAdo: datAdo,
datAdu: datAdu,
datAc: datAc,
datCir: datCir,
estadoEspera: estadoEspera,
);
}
@action
Future<bool> savePatient() async {
if (!isValid) {
errorMessage = 'Faltan campos obligatorios: Nombre, Apellido, Sexo y Seguro.';
return false;
}
// Si el paciente estaba en consulta o en espera y el médico guarda los cambios de la consulta médica,
// la atención concluye formalmente y su estado de sala de espera pasa a 'atendido'.
if (estadoEspera == 'en_consulta' || estadoEspera == 'en_espera') {
estadoEspera = 'atendido';
}
isSaving = true;
errorMessage = null;
successMessage = null;
try {
final model = toModel();
if (isEditing) {
await service.updatePatient(id!, model.toJson(includeId: false));
runInAction(() {
successMessage = 'Paciente ${model.fullName} actualizado correctamente';
isSaving = false;
});
} else {
final created = await service.createPatient(model);
runInAction(() {
id = created.id;
successMessage = 'Paciente ${model.fullName} guardado correctamente';
isSaving = false;
});
}
return true;
} catch (e) {
runInAction(() {
errorMessage = e.toString().replaceAll('Exception: ', '');
isSaving = false;
});
return false;
}
}
}
@@ -0,0 +1,873 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'patient_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 _$PatientStore on _PatientStoreBase, Store {
Computed<bool>? _$isEditingComputed;
@override
bool get isEditing => (_$isEditingComputed ??= Computed<bool>(
() => super.isEditing,
name: '_PatientStoreBase.isEditing',
)).value;
Computed<bool>? _$isValidComputed;
@override
bool get isValid => (_$isValidComputed ??= Computed<bool>(
() => super.isValid,
name: '_PatientStoreBase.isValid',
)).value;
Computed<int?>? _$diasParaProximaCitaComputed;
@override
int? get diasParaProximaCita =>
(_$diasParaProximaCitaComputed ??= Computed<int?>(
() => super.diasParaProximaCita,
name: '_PatientStoreBase.diasParaProximaCita',
)).value;
late final _$idAtom = Atom(name: '_PatientStoreBase.id', context: context);
@override
int? get id {
_$idAtom.reportRead();
return super.id;
}
@override
set id(int? value) {
_$idAtom.reportWrite(value, super.id, () {
super.id = value;
});
}
late final _$isLoadingAtom = Atom(
name: '_PatientStoreBase.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 _$isSavingAtom = Atom(
name: '_PatientStoreBase.isSaving',
context: context,
);
@override
bool get isSaving {
_$isSavingAtom.reportRead();
return super.isSaving;
}
@override
set isSaving(bool value) {
_$isSavingAtom.reportWrite(value, super.isSaving, () {
super.isSaving = value;
});
}
late final _$errorMessageAtom = Atom(
name: '_PatientStoreBase.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 _$successMessageAtom = Atom(
name: '_PatientStoreBase.successMessage',
context: context,
);
@override
String? get successMessage {
_$successMessageAtom.reportRead();
return super.successMessage;
}
@override
set successMessage(String? value) {
_$successMessageAtom.reportWrite(value, super.successMessage, () {
super.successMessage = value;
});
}
late final _$fecRegAtom = Atom(
name: '_PatientStoreBase.fecReg',
context: context,
);
@override
String get fecReg {
_$fecRegAtom.reportRead();
return super.fecReg;
}
@override
set fecReg(String value) {
_$fecRegAtom.reportWrite(value, super.fecReg, () {
super.fecReg = value;
});
}
late final _$cedulaAtom = Atom(
name: '_PatientStoreBase.cedula',
context: context,
);
@override
String get cedula {
_$cedulaAtom.reportRead();
return super.cedula;
}
@override
set cedula(String value) {
_$cedulaAtom.reportWrite(value, super.cedula, () {
super.cedula = value;
});
}
late final _$nombreAtom = Atom(
name: '_PatientStoreBase.nombre',
context: context,
);
@override
String get nombre {
_$nombreAtom.reportRead();
return super.nombre;
}
@override
set nombre(String value) {
_$nombreAtom.reportWrite(value, super.nombre, () {
super.nombre = value;
});
}
late final _$apellidoAtom = Atom(
name: '_PatientStoreBase.apellido',
context: context,
);
@override
String get apellido {
_$apellidoAtom.reportRead();
return super.apellido;
}
@override
set apellido(String value) {
_$apellidoAtom.reportWrite(value, super.apellido, () {
super.apellido = value;
});
}
late final _$direccionAtom = Atom(
name: '_PatientStoreBase.direccion',
context: context,
);
@override
String get direccion {
_$direccionAtom.reportRead();
return super.direccion;
}
@override
set direccion(String value) {
_$direccionAtom.reportWrite(value, super.direccion, () {
super.direccion = value;
});
}
late final _$telefonoAtom = Atom(
name: '_PatientStoreBase.telefono',
context: context,
);
@override
String get telefono {
_$telefonoAtom.reportRead();
return super.telefono;
}
@override
set telefono(String value) {
_$telefonoAtom.reportWrite(value, super.telefono, () {
super.telefono = value;
});
}
late final _$ocupacionAtom = Atom(
name: '_PatientStoreBase.ocupacion',
context: context,
);
@override
String get ocupacion {
_$ocupacionAtom.reportRead();
return super.ocupacion;
}
@override
set ocupacion(String value) {
_$ocupacionAtom.reportWrite(value, super.ocupacion, () {
super.ocupacion = value;
});
}
late final _$edadAtom = Atom(
name: '_PatientStoreBase.edad',
context: context,
);
@override
String get edad {
_$edadAtom.reportRead();
return super.edad;
}
@override
set edad(String value) {
_$edadAtom.reportWrite(value, super.edad, () {
super.edad = value;
});
}
late final _$sexoAtom = Atom(
name: '_PatientStoreBase.sexo',
context: context,
);
@override
String get sexo {
_$sexoAtom.reportRead();
return super.sexo;
}
@override
set sexo(String value) {
_$sexoAtom.reportWrite(value, super.sexo, () {
super.sexo = value;
});
}
late final _$seguroAtom = Atom(
name: '_PatientStoreBase.seguro',
context: context,
);
@override
String get seguro {
_$seguroAtom.reportRead();
return super.seguro;
}
@override
set seguro(String value) {
_$seguroAtom.reportWrite(value, super.seguro, () {
super.seguro = value;
});
}
late final _$estCivilAtom = Atom(
name: '_PatientStoreBase.estCivil',
context: context,
);
@override
String get estCivil {
_$estCivilAtom.reportRead();
return super.estCivil;
}
@override
set estCivil(String value) {
_$estCivilAtom.reportWrite(value, super.estCivil, () {
super.estCivil = value;
});
}
late final _$motConsAtom = Atom(
name: '_PatientStoreBase.motCons',
context: context,
);
@override
String get motCons {
_$motConsAtom.reportRead();
return super.motCons;
}
@override
set motCons(String value) {
_$motConsAtom.reportWrite(value, super.motCons, () {
super.motCons = value;
});
}
late final _$fecUltconAtom = Atom(
name: '_PatientStoreBase.fecUltcon',
context: context,
);
@override
String get fecUltcon {
_$fecUltconAtom.reportRead();
return super.fecUltcon;
}
@override
set fecUltcon(String value) {
_$fecUltconAtom.reportWrite(value, super.fecUltcon, () {
super.fecUltcon = value;
});
}
late final _$hisEnfActAtom = Atom(
name: '_PatientStoreBase.hisEnfAct',
context: context,
);
@override
String get hisEnfAct {
_$hisEnfActAtom.reportRead();
return super.hisEnfAct;
}
@override
set hisEnfAct(String value) {
_$hisEnfActAtom.reportWrite(value, super.hisEnfAct, () {
super.hisEnfAct = value;
});
}
late final _$datNiAtom = Atom(
name: '_PatientStoreBase.datNi',
context: context,
);
@override
String get datNi {
_$datNiAtom.reportRead();
return super.datNi;
}
@override
set datNi(String value) {
_$datNiAtom.reportWrite(value, super.datNi, () {
super.datNi = value;
});
}
late final _$datAdoAtom = Atom(
name: '_PatientStoreBase.datAdo',
context: context,
);
@override
String get datAdo {
_$datAdoAtom.reportRead();
return super.datAdo;
}
@override
set datAdo(String value) {
_$datAdoAtom.reportWrite(value, super.datAdo, () {
super.datAdo = value;
});
}
late final _$datAduAtom = Atom(
name: '_PatientStoreBase.datAdu',
context: context,
);
@override
String get datAdu {
_$datAduAtom.reportRead();
return super.datAdu;
}
@override
set datAdu(String value) {
_$datAduAtom.reportWrite(value, super.datAdu, () {
super.datAdu = value;
});
}
late final _$datAlergAtom = Atom(
name: '_PatientStoreBase.datAlerg',
context: context,
);
@override
String get datAlerg {
_$datAlergAtom.reportRead();
return super.datAlerg;
}
@override
set datAlerg(String value) {
_$datAlergAtom.reportWrite(value, super.datAlerg, () {
super.datAlerg = value;
});
}
late final _$datTranAtom = Atom(
name: '_PatientStoreBase.datTran',
context: context,
);
@override
String get datTran {
_$datTranAtom.reportRead();
return super.datTran;
}
@override
set datTran(String value) {
_$datTranAtom.reportWrite(value, super.datTran, () {
super.datTran = value;
});
}
late final _$datCirAtom = Atom(
name: '_PatientStoreBase.datCir',
context: context,
);
@override
String get datCir {
_$datCirAtom.reportRead();
return super.datCir;
}
@override
set datCir(String value) {
_$datCirAtom.reportWrite(value, super.datCir, () {
super.datCir = value;
});
}
late final _$datAcAtom = Atom(
name: '_PatientStoreBase.datAc',
context: context,
);
@override
String get datAc {
_$datAcAtom.reportRead();
return super.datAc;
}
@override
set datAc(String value) {
_$datAcAtom.reportWrite(value, super.datAc, () {
super.datAc = value;
});
}
late final _$datOtrospAtom = Atom(
name: '_PatientStoreBase.datOtrosp',
context: context,
);
@override
String get datOtrosp {
_$datOtrospAtom.reportRead();
return super.datOtrosp;
}
@override
set datOtrosp(String value) {
_$datOtrospAtom.reportWrite(value, super.datOtrosp, () {
super.datOtrosp = value;
});
}
late final _$datMadAtom = Atom(
name: '_PatientStoreBase.datMad',
context: context,
);
@override
String get datMad {
_$datMadAtom.reportRead();
return super.datMad;
}
@override
set datMad(String value) {
_$datMadAtom.reportWrite(value, super.datMad, () {
super.datMad = value;
});
}
late final _$datEspAtom = Atom(
name: '_PatientStoreBase.datEsp',
context: context,
);
@override
String get datEsp {
_$datEspAtom.reportRead();
return super.datEsp;
}
@override
set datEsp(String value) {
_$datEspAtom.reportWrite(value, super.datEsp, () {
super.datEsp = value;
});
}
late final _$datPadreAtom = Atom(
name: '_PatientStoreBase.datPadre',
context: context,
);
@override
String get datPadre {
_$datPadreAtom.reportRead();
return super.datPadre;
}
@override
set datPadre(String value) {
_$datPadreAtom.reportWrite(value, super.datPadre, () {
super.datPadre = value;
});
}
late final _$datHijosAtom = Atom(
name: '_PatientStoreBase.datHijos',
context: context,
);
@override
String get datHijos {
_$datHijosAtom.reportRead();
return super.datHijos;
}
@override
set datHijos(String value) {
_$datHijosAtom.reportWrite(value, super.datHijos, () {
super.datHijos = value;
});
}
late final _$datHerAtom = Atom(
name: '_PatientStoreBase.datHer',
context: context,
);
@override
String get datHer {
_$datHerAtom.reportRead();
return super.datHer;
}
@override
set datHer(String value) {
_$datHerAtom.reportWrite(value, super.datHer, () {
super.datHer = value;
});
}
late final _$datOtrosnpAtom = Atom(
name: '_PatientStoreBase.datOtrosnp',
context: context,
);
@override
String get datOtrosnp {
_$datOtrosnpAtom.reportRead();
return super.datOtrosnp;
}
@override
set datOtrosnp(String value) {
_$datOtrosnpAtom.reportWrite(value, super.datOtrosnp, () {
super.datOtrosnp = value;
});
}
late final _$metDiagnRadAtom = Atom(
name: '_PatientStoreBase.metDiagnRad',
context: context,
);
@override
String get metDiagnRad {
_$metDiagnRadAtom.reportRead();
return super.metDiagnRad;
}
@override
set metDiagnRad(String value) {
_$metDiagnRadAtom.reportWrite(value, super.metDiagnRad, () {
super.metDiagnRad = value;
});
}
late final _$diagnDescAtom = Atom(
name: '_PatientStoreBase.diagnDesc',
context: context,
);
@override
String get diagnDesc {
_$diagnDescAtom.reportRead();
return super.diagnDesc;
}
@override
set diagnDesc(String value) {
_$diagnDescAtom.reportWrite(value, super.diagnDesc, () {
super.diagnDesc = value;
});
}
late final _$diagnDefAtom = Atom(
name: '_PatientStoreBase.diagnDef',
context: context,
);
@override
String get diagnDef {
_$diagnDefAtom.reportRead();
return super.diagnDef;
}
@override
set diagnDef(String value) {
_$diagnDefAtom.reportWrite(value, super.diagnDef, () {
super.diagnDef = value;
});
}
late final _$datLabAtom = Atom(
name: '_PatientStoreBase.datLab',
context: context,
);
@override
String get datLab {
_$datLabAtom.reportRead();
return super.datLab;
}
@override
set datLab(String value) {
_$datLabAtom.reportWrite(value, super.datLab, () {
super.datLab = value;
});
}
late final _$tratamientoAtom = Atom(
name: '_PatientStoreBase.tratamiento',
context: context,
);
@override
String get tratamiento {
_$tratamientoAtom.reportRead();
return super.tratamiento;
}
@override
set tratamiento(String value) {
_$tratamientoAtom.reportWrite(value, super.tratamiento, () {
super.tratamiento = value;
});
}
late final _$fecProxcitaAtom = Atom(
name: '_PatientStoreBase.fecProxcita',
context: context,
);
@override
String get fecProxcita {
_$fecProxcitaAtom.reportRead();
return super.fecProxcita;
}
@override
set fecProxcita(String value) {
_$fecProxcitaAtom.reportWrite(value, super.fecProxcita, () {
super.fecProxcita = value;
});
}
late final _$estadoEsperaAtom = Atom(
name: '_PatientStoreBase.estadoEspera',
context: context,
);
@override
String get estadoEspera {
_$estadoEsperaAtom.reportRead();
return super.estadoEspera;
}
@override
set estadoEspera(String value) {
_$estadoEsperaAtom.reportWrite(value, super.estadoEspera, () {
super.estadoEspera = value;
});
}
late final _$loadPatientAsyncAction = AsyncAction(
'_PatientStoreBase.loadPatient',
context: context,
);
@override
Future<void> loadPatient(int patientId) {
return _$loadPatientAsyncAction.run(() => super.loadPatient(patientId));
}
late final _$savePatientAsyncAction = AsyncAction(
'_PatientStoreBase.savePatient',
context: context,
);
@override
Future<bool> savePatient() {
return _$savePatientAsyncAction.run(() => super.savePatient());
}
late final _$_PatientStoreBaseActionController = ActionController(
name: '_PatientStoreBase',
context: context,
);
@override
void initForCreate() {
final _$actionInfo = _$_PatientStoreBaseActionController.startAction(
name: '_PatientStoreBase.initForCreate',
);
try {
return super.initForCreate();
} finally {
_$_PatientStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void populateFromModel(PatientModel p) {
final _$actionInfo = _$_PatientStoreBaseActionController.startAction(
name: '_PatientStoreBase.populateFromModel',
);
try {
return super.populateFromModel(p);
} finally {
_$_PatientStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void appendCurrentDateToConsultas() {
final _$actionInfo = _$_PatientStoreBaseActionController.startAction(
name: '_PatientStoreBase.appendCurrentDateToConsultas',
);
try {
return super.appendCurrentDateToConsultas();
} finally {
_$_PatientStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void appendCurrentDateToHistorial() {
final _$actionInfo = _$_PatientStoreBaseActionController.startAction(
name: '_PatientStoreBase.appendCurrentDateToHistorial',
);
try {
return super.appendCurrentDateToHistorial();
} finally {
_$_PatientStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
id: ${id},
isLoading: ${isLoading},
isSaving: ${isSaving},
errorMessage: ${errorMessage},
successMessage: ${successMessage},
fecReg: ${fecReg},
cedula: ${cedula},
nombre: ${nombre},
apellido: ${apellido},
direccion: ${direccion},
telefono: ${telefono},
ocupacion: ${ocupacion},
edad: ${edad},
sexo: ${sexo},
seguro: ${seguro},
estCivil: ${estCivil},
motCons: ${motCons},
fecUltcon: ${fecUltcon},
hisEnfAct: ${hisEnfAct},
datNi: ${datNi},
datAdo: ${datAdo},
datAdu: ${datAdu},
datAlerg: ${datAlerg},
datTran: ${datTran},
datCir: ${datCir},
datAc: ${datAc},
datOtrosp: ${datOtrosp},
datMad: ${datMad},
datEsp: ${datEsp},
datPadre: ${datPadre},
datHijos: ${datHijos},
datHer: ${datHer},
datOtrosnp: ${datOtrosnp},
metDiagnRad: ${metDiagnRad},
diagnDesc: ${diagnDesc},
diagnDef: ${diagnDef},
datLab: ${datLab},
tratamiento: ${tratamiento},
fecProxcita: ${fecProxcita},
estadoEspera: ${estadoEspera},
isEditing: ${isEditing},
isValid: ${isValid},
diasParaProximaCita: ${diasParaProximaCita}
''';
}
}
@@ -0,0 +1,352 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/medical_modal.dart';
import '../../../insurance_auth/presentation/views/insurance_auth_modal_view.dart';
import '../../../prescription/presentation/views/prescription_modal_view.dart';
import '../../../referrals/presentation/views/department_referral_modal_view.dart';
import '../../../referrals/presentation/views/doctor_referral_modal_view.dart';
import '../../data/models/patient_model.dart';
import '../store/patient_store.dart';
import '../widgets/clinical_documents_dropdown.dart';
import '../widgets/patient_clinical_tabs.dart';
import '../widgets/patient_demographics_form.dart';
import '../widgets/secretary_appointment_section.dart';
class PatientModalView extends StatefulWidget {
final PatientModel? initialPatient;
final int? patientId;
final ValueChanged<PatientModel>? onSaved;
final bool isSecretary;
const PatientModalView({
super.key,
this.initialPatient,
this.patientId,
this.onSaved,
this.isSecretary = false,
});
@override
State<PatientModalView> createState() => _PatientModalViewState();
}
class _PatientModalViewState extends State<PatientModalView> {
late final PatientStore _store;
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
_store = PatientStore();
if (widget.initialPatient != null) {
_store.populateFromModel(widget.initialPatient!);
} else if (widget.patientId != null) {
_store.loadPatient(widget.patientId!);
} else {
_store.initForCreate();
}
}
Future<void> _handleSave() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
if (values['nombre'] != null) _store.nombre = values['nombre'].toString();
if (values['apellido'] != null) _store.apellido = values['apellido'].toString();
if (values['cedula'] != null) _store.cedula = values['cedula'].toString();
if (values['telefono'] != null) _store.telefono = values['telefono'].toString();
if (values['direccion'] != null) _store.direccion = values['direccion'].toString();
if (values['ocupacion'] != null) _store.ocupacion = values['ocupacion'].toString();
if (values['edad'] != null) _store.edad = values['edad'].toString();
if (values['sexo'] != null) _store.sexo = values['sexo'].toString();
if (values['seguro'] != null) _store.seguro = values['seguro'].toString();
if (values['est_civil'] != null) _store.estCivil = values['est_civil'].toString();
final success = await _store.savePatient();
if (success && mounted) {
if (widget.onSaved != null) {
widget.onSaved!(_store.toModel());
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_store.successMessage ?? 'Operación completada exitosamente'),
backgroundColor: const Color(0xFF00A65A),
behavior: SnackBarBehavior.floating,
),
);
Navigator.of(context).pop(_store.toModel());
}
} else {
_store.errorMessage = 'Por favor complete los campos obligatorios resaltados en rojo.';
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final title = widget.isSecretary
? (_store.isEditing ? 'Ficha de Recepción' : 'Nuevo Registro de Paciente')
: (_store.isEditing ? 'Editar Paciente' : 'Registro De Paciente');
final subtitle = widget.isSecretary
? 'Recepción, datos personales y programación de citas'
: (_store.isEditing
? 'Actualizando ficha clínica del paciente ID: ${_store.id ?? ''}'
: 'Ingrese los datos generales y antecedentes del paciente');
final mediaQuery = MediaQuery.sizeOf(context);
final modalHeight = (mediaQuery.height * 0.92).clamp(740.0, 900.0);
final modalWidth = (mediaQuery.width * 0.92).clamp(980.0, 1160.0);
return MedicalModalDialog(
maxWidth: modalWidth,
maxHeight: modalHeight,
title: title,
titleIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_store.isEditing ? Icons.edit_note_rounded : Icons.person_add_alt_1_rounded,
color: AppColors.primary,
size: 20,
),
),
subtitle: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
actions: [
if (_store.estadoEspera == 'en_consulta') ...[
Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: const Color(0xFFE0F2FE),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF7DD3FC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_services_rounded, size: 13, color: Color(0xFF0369A1)),
SizedBox(width: 5),
Text(
'En Consulta',
style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w700, color: Color(0xFF0369A1)),
),
],
),
),
const SizedBox(width: 8),
] else if (_store.estadoEspera == 'en_espera') ...[
Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF86EFAC)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_top_rounded, size: 13, color: Color(0xFF15803D)),
SizedBox(width: 5),
Text(
'En Espera',
style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w700, color: Color(0xFF15803D)),
),
],
),
),
const SizedBox(width: 8),
] else if (_store.estadoEspera == 'atendido') ...[
Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFCBD5E1)),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_rounded, size: 13, color: Color(0xFF64748B)),
SizedBox(width: 5),
Text(
'Atendido',
style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: Color(0xFF64748B)),
),
],
),
),
const SizedBox(width: 8),
],
if (!widget.isSecretary)
ClinicalDocumentsDropdown(
onSelect: (docType) {
if (docType == ClinicalDocumentType.recetaMedica) {
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
showMedicalModal(
context: context,
builder: (modalCtx) => PrescriptionModalView(
patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre',
),
);
} else if (docType == ClinicalDocumentType.autorizacionSeguro) {
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
final diagnosis = _store.diagnDesc.trim().isNotEmpty
? _store.diagnDesc.trim()
: (_store.diagnDef.trim().isNotEmpty ? _store.diagnDef.trim() : null);
showMedicalModal(
context: context,
builder: (modalCtx) => InsuranceAuthModalView(
patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre',
initialDiagnosis: diagnosis,
),
);
} else if (docType == ClinicalDocumentType.referirDoctor) {
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
showMedicalModal(
context: context,
builder: (modalCtx) => DoctorReferralModalView(
patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre',
),
);
} else if (docType == ClinicalDocumentType.referirDepartamento) {
final patientName = '${_store.nombre} ${_store.apellido}'.trim();
showMedicalModal(
context: context,
builder: (modalCtx) => DepartmentReferralModalView(
patientName: patientName.isNotEmpty ? patientName : 'Sin Nombre',
),
);
}
},
),
],
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: _store.errorMessage != null
? AppColors.error.withValues(alpha: 0.12)
: (_store.successMessage != null
? const Color(0xFF00A65A).withValues(alpha: 0.12)
: AppColors.primary.withValues(alpha: 0.1)),
shape: BoxShape.circle,
),
child: Icon(
_store.errorMessage != null
? Icons.error_outline_rounded
: (_store.successMessage != null
? Icons.check_circle_outline_rounded
: Icons.info_outline_rounded),
size: 15,
color: _store.errorMessage != null
? AppColors.error
: (_store.successMessage != null
? const Color(0xFF00A65A)
: AppColors.primary),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_store.errorMessage ??
(_store.successMessage ??
(_store.isSaving
? 'Guardando datos en el servidor...'
: (_store.estadoEspera == 'en_consulta'
? 'Consulta médica activa · Al guardar pasará automáticamente a Atendido'
: (_store.isEditing
? 'Realizando cambios en paciente'
: 'Listo para registrar')))),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _store.errorMessage != null
? AppColors.error
: (_store.successMessage != null
? const Color(0xFF00A65A)
: 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),
),
],
),
),
],
),
),
child: _store.isLoading
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(strokeWidth: 3),
SizedBox(height: 16),
Text('Cargando ficha del paciente...', style: TextStyle(fontSize: 13)),
],
),
)
: FormBuilder(
key: _formKey,
child: Column(
children: [
PatientDemographicsForm(
store: _store,
formKey: _formKey,
onSave: _handleSave,
),
if (widget.isSecretary)
Expanded(
child: SingleChildScrollView(
child: SecretaryAppointmentSection(store: _store),
),
)
else
Expanded(
child: PatientClinicalTabs(store: _store),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
enum ClinicalDocumentType {
recetaMedica,
autorizacionSeguro,
referirDoctor,
referirDepartamento,
}
class ClinicalDocumentsDropdown extends StatelessWidget {
final ValueChanged<ClinicalDocumentType> onSelect;
const ClinicalDocumentsDropdown({
super.key,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<ClinicalDocumentType>(
tooltip: 'Documentos Clínicos e Impresión',
offset: const Offset(0, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE2E8F0)),
),
elevation: 6,
color: Colors.white,
onSelected: onSelect,
itemBuilder: (context) => [
PopupMenuItem(
value: ClinicalDocumentType.recetaMedica,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.medication_rounded,
color: Color(0xFF00A65A),
size: 18,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Receta Médica',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: AppColors.textPrimary,
),
),
Text(
'Formato media carta institucional',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
],
),
),
const PopupMenuDivider(height: 1),
PopupMenuItem(
value: ClinicalDocumentType.autorizacionSeguro,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.verified_user_outlined,
color: Color(0xFF00A65A),
size: 18,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Autorización De Seguro',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: AppColors.textPrimary,
),
),
Text(
'Solicitud oficial de cobertura ARS',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
],
),
),
const PopupMenuDivider(height: 1),
PopupMenuItem(
value: ClinicalDocumentType.referirDoctor,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.person_pin_circle_outlined,
color: Color(0xFF00A65A),
size: 18,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Referir a Doctor(a)',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: AppColors.textPrimary,
),
),
Text(
'Interconsulta médica a especialista',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
],
),
),
const PopupMenuDivider(height: 1),
PopupMenuItem(
value: ClinicalDocumentType.referirDepartamento,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.apartment_rounded,
color: Color(0xFF00A65A),
size: 18,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Referir a Departamento',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: AppColors.textPrimary,
),
),
Text(
'Referimiento a servicio institucional',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFCBD5E1)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.description_outlined, color: AppColors.primary, size: 16),
SizedBox(width: 6),
Text(
'Documentos Clínicos',
style: TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 12.5,
),
),
SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down_rounded, color: AppColors.textSecondary, size: 16),
],
),
),
);
}
}
@@ -0,0 +1,939 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:intl/intl.dart';
import 'package:mobx/mobx.dart';
import '../../../../core/theme/app_colors.dart';
import '../store/patient_store.dart';
class PatientClinicalTabs extends StatefulWidget {
final PatientStore store;
const PatientClinicalTabs({super.key, required this.store});
@override
State<PatientClinicalTabs> createState() => _PatientClinicalTabsState();
}
class _PatientClinicalTabsState extends State<PatientClinicalTabs>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final TextEditingController _motConsController;
late final FocusNode _motConsFocusNode;
late final TextEditingController _hisEnfActController;
late final FocusNode _hisEnfActFocusNode;
ReactionDisposer? _disposerMotCons;
ReactionDisposer? _disposerHisEnfAct;
@override
void initState() {
super.initState();
_tabController = TabController(length: 7, vsync: this);
_motConsController = TextEditingController(text: widget.store.motCons);
_motConsFocusNode = FocusNode();
_hisEnfActController = TextEditingController(text: widget.store.hisEnfAct);
_hisEnfActFocusNode = FocusNode();
_disposerMotCons = reaction((_) => widget.store.motCons, (String val) {
if (_motConsController.text != val) {
_motConsController.text = val;
}
});
_disposerHisEnfAct = reaction((_) => widget.store.hisEnfAct, (String val) {
if (_hisEnfActController.text != val) {
_hisEnfActController.text = val;
}
});
}
@override
void dispose() {
_disposerMotCons?.call();
_disposerHisEnfAct?.call();
_tabController.dispose();
_motConsController.dispose();
_motConsFocusNode.dispose();
_hisEnfActController.dispose();
_hisEnfActFocusNode.dispose();
super.dispose();
}
void _insertDateAtCursor({
required TextEditingController controller,
required FocusNode focusNode,
required ValueChanged<String> onUpdateStore,
}) {
final now = DateTime.now();
final dateStr = DateFormat('dd/MM/yyyy').format(now);
final insertionText = '$dateStr: ';
final currentText = controller.text;
final selection = controller.selection;
int start = selection.start;
int end = selection.end;
// Si el cursor no es válido o no está enfocado, insertar al final
if (start < 0 || end < 0) {
start = currentText.length;
end = currentText.length;
}
if (start > end) {
final temp = start;
start = end;
end = temp;
}
final before = currentText.substring(0, start);
final after = currentText.substring(end);
final newText = '$before$insertionText$after';
final newOffset = start + insertionText.length;
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newOffset),
);
onUpdateStore(newText);
focusNode.requestFocus();
}
InputDecoration _cleanTextAreaDecoration(String hintText) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
);
}
InputDecoration _cleanDropdownDecoration(String label) {
return InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
floatingLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
hintStyle: const TextStyle(
fontSize: 13.5,
color: Color(0xFF94A3B8),
),
);
}
Widget _buildClinicalTextArea({
required String name,
String? initialValue,
TextEditingController? controller,
FocusNode? focusNode,
required ValueChanged<String> onChanged,
required String hintText,
VoidCallback? onInsertDate,
String? dateLabel,
}) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: FormBuilderTextField(
name: name,
controller: controller,
initialValue: controller == null ? initialValue : null,
focusNode: focusNode,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary),
decoration: _cleanTextAreaDecoration(hintText),
onChanged: (v) => onChanged(v ?? ''),
),
),
if (onInsertDate != null) ...[
const SizedBox(width: 16),
SizedBox(
width: 200,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (dateLabel != null && dateLabel.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
),
child: Row(
children: [
const Icon(Icons.history_rounded, size: 16, color: AppColors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
dateLabel,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
],
),
),
const SizedBox(height: 12),
],
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Entrada de Consulta',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
const Text(
'Inserte el sello de tiempo para documentar una nueva nota clínica.',
style: TextStyle(fontSize: 11, color: AppColors.textMuted, height: 1.3),
),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: onInsertDate,
icon: const Icon(Icons.add_alarm_rounded, size: 16),
label: const Text('Fecha actual', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
),
],
),
),
],
),
),
),
],
],
),
);
}
Widget _buildDropdownItem({
required String name,
required String label,
required String initialValue,
required List<String> items,
required ValueChanged<String> onChanged,
}) {
final list = items.contains(initialValue)
? items
: (initialValue.isNotEmpty ? [initialValue, ...items] : items);
return FormBuilderDropdown<String>(
name: name,
initialValue: initialValue.isNotEmpty ? initialValue : null,
decoration: _cleanDropdownDecoration(label),
hint: const Text(
'Seleccionar...',
style: TextStyle(fontSize: 13.5, color: Color(0xFF94A3B8)),
),
style: const TextStyle(fontSize: 13.5, color: AppColors.textPrimary, fontWeight: FontWeight.w500),
onChanged: (v) {
if (v != null) onChanged(v);
},
items: list.map((item) {
return DropdownMenuItem<String>(
value: item,
child: Text(
item,
style: const TextStyle(fontSize: 13.5),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
);
}
Widget _buildAntecedentesTab(PatientStore s) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Patológicos
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.healing_rounded, size: 18, color: AppColors.primary),
SizedBox(width: 8),
Text(
'Antecedentes Personales Patológicos',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _buildDropdownItem(
name: 'dat_ni',
label: 'Infancia',
initialValue: s.datNi,
items: const ['sarampión', 'varicela', 'Tos ferina', 'Hepatitis', 'Ninguna'],
onChanged: (v) => s.datNi = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_ado',
label: 'Adolescencia',
initialValue: s.datAdo,
items: const ['Asma', 'Trastornos alimenticios', 'TBC', 'VIH', 'Ninguna'],
onChanged: (v) => s.datAdo = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_adu',
label: 'Adultez',
initialValue: s.datAdu,
items: const [
'TBC',
'VIH',
'Hepatopatías',
'Cardiopatías',
'Diabetes',
'HTA',
'Dislipidemias',
'Ninguna',
],
onChanged: (v) => s.datAdu = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_alerg',
label: 'Alergias',
initialValue: s.datAlerg,
items: const ['Ninguna', 'Penicilina', 'Sulfas', 'AINEs'],
onChanged: (v) => s.datAlerg = v,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildDropdownItem(
name: 'dat_tran',
label: 'Transfusión sanguínea',
initialValue: s.datTran,
items: const ['1 ves', '2 veces', '3 veces', 'ninguna'],
onChanged: (v) => s.datTran = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_cir',
label: 'Intervenciones quirúrgicas',
initialValue: s.datCir,
items: const ['ninguna', 'Apendicectomía', 'Cesárea', 'Colecistectomía'],
onChanged: (v) => s.datCir = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_ac',
label: 'Accidentes',
initialValue: s.datAc,
items: const ['Si', 'ninguno'],
onChanged: (v) => s.datAc = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_otrosp',
label: 'Otros patológicos',
initialValue: s.datOtrosp,
items: const ['ninguno'],
onChanged: (v) => s.datOtrosp = v,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// No patológicos
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.family_restroom_rounded, size: 18, color: AppColors.primary),
SizedBox(width: 8),
Text(
'Antecedentes Personales No Patológicos (Familiares)',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _buildDropdownItem(
name: 'dat_mad',
label: 'Madre',
initialValue: s.datMad,
items: const ['Viva y sana', 'Fallecida', 'Enferma'],
onChanged: (v) => s.datMad = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_esp',
label: 'Pareja',
initialValue: s.datEsp,
items: const ['Viva y sana', 'Fallecida', 'Enferma'],
onChanged: (v) => s.datEsp = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_padre',
label: 'Padre',
initialValue: s.datPadre,
items: const ['Vivo y sano', 'Fallecido', 'Enfermo'],
onChanged: (v) => s.datPadre = v,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildDropdownItem(
name: 'dat_hijos',
label: 'Hijos',
initialValue: s.datHijos,
items: const ['Vivos y sanos', 'Enfermos', 'Ninguno'],
onChanged: (v) => s.datHijos = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_her',
label: 'Hermanos',
initialValue: s.datHer,
items: const ['Vivos y sanos', 'Fallecidos', 'Enfermos', 'Ninguno'],
onChanged: (v) => s.datHer = v,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdownItem(
name: 'dat_otrosnp',
label: 'Otros no patológicos',
initialValue: s.datOtrosnp,
items: const ['Ningunos'],
onChanged: (v) => s.datOtrosnp = v,
),
),
],
),
],
),
),
],
),
);
}
Widget _buildDiagnosticosTab(PatientStore s) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.biotech_rounded, size: 16, color: AppColors.primary),
SizedBox(width: 6),
Text(
'Métodos diagnósticos, radiológicos e invasivos',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
],
),
const SizedBox(height: 8),
Expanded(
child: FormBuilderTextField(
name: 'met_diagn_rad',
initialValue: s.metDiagnRad,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 13),
decoration: _cleanTextAreaDecoration('Describa radiografías, ecografías, resonancias o procedimientos invasivos...'),
onChanged: (v) => s.metDiagnRad = v ?? '',
),
),
const SizedBox(height: 12),
const Row(
children: [
Icon(Icons.star_outline_rounded, size: 16, color: AppColors.primary),
SizedBox(width: 6),
Text(
'Diagnósticos a destacar',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
],
),
const SizedBox(height: 8),
Expanded(
child: FormBuilderTextField(
name: 'diagn_desc',
initialValue: s.diagnDesc,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 13),
decoration: _cleanTextAreaDecoration('Patologías principales, hallazgos o diagnósticos relevantes...'),
onChanged: (v) => s.diagnDesc = v ?? '',
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.verified_outlined, size: 16, color: AppColors.primary),
SizedBox(width: 6),
Text(
'Diagnóstico definitivo',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
],
),
const SizedBox(height: 8),
Expanded(
child: FormBuilderTextField(
name: 'diagn_def',
initialValue: s.diagnDef,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 13),
decoration: _cleanTextAreaDecoration('Conclusión diagnóstica definitiva y codificación clínica...'),
onChanged: (v) => s.diagnDef = v ?? '',
),
),
],
),
),
],
),
);
}
Widget _buildProximaCitaTab(PatientStore s) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ElevatedButton.icon(
onPressed: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: now.add(const Duration(days: 7)),
firstDate: now.subtract(const Duration(days: 30)),
lastDate: now.add(const Duration(days: 365 * 3)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
),
),
child: child!,
);
},
);
if (picked != null) {
s.fecProxcita = DateFormat('dd/MM/yyyy').format(picked);
}
},
icon: const Icon(Icons.calendar_month_rounded, size: 18),
label: const Text('Seleccionar Fecha de Cita', style: TextStyle(fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
),
const SizedBox(width: 14),
if (s.fecProxcita.isNotEmpty)
TextButton.icon(
onPressed: () => s.fecProxcita = '',
icon: const Icon(Icons.clear_rounded, size: 16, color: AppColors.error),
label: const Text('Quitar próxima cita', style: TextStyle(color: AppColors.error, fontWeight: FontWeight.w600)),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.event_available_rounded, size: 22, color: AppColors.primary),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Control de Citas del Paciente',
style: TextStyle(fontSize: 12, color: AppColors.textMuted, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
s.fecProxcita.isNotEmpty
? 'Próxima cita programada: ${s.fecProxcita}'
: 'Sin próxima cita programada',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
],
),
if (s.diasParaProximaCita != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: s.diasParaProximaCita! < 0
? AppColors.error.withValues(alpha: 0.1)
: (s.diasParaProximaCita! == 0
? AppColors.warning.withValues(alpha: 0.1)
: AppColors.success.withValues(alpha: 0.1)),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Tiempo restante para la cita: ${s.diasParaProximaCita} días',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: s.diasParaProximaCita! < 0
? AppColors.error
: (s.diasParaProximaCita! == 0 ? AppColors.warning : AppColors.success),
),
),
),
],
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final s = widget.store;
return Column(
children: [
// Barra de Pestañas Moderna con Estilo Limpio
Container(
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
border: Border(
bottom: BorderSide(color: Color(0xFFE2E8F0)),
),
),
child: TabBar(
controller: _tabController,
isScrollable: true,
labelColor: AppColors.primary,
unselectedLabelColor: AppColors.textSecondary,
labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12.5),
indicatorColor: AppColors.primary,
indicatorWeight: 2.5,
tabAlignment: TabAlignment.start,
tabs: const [
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notes_rounded, size: 15),
SizedBox(width: 6),
Text('Consultas'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history_edu_rounded, size: 15),
SizedBox(width: 6),
Text('Hist. enfermedad actual'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medical_information_outlined, size: 15),
SizedBox(width: 6),
Text('Antec. patológicos'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.biotech_outlined, size: 15),
SizedBox(width: 6),
Text('Diagnósticos'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.science_outlined, size: 15),
SizedBox(width: 6),
Text('Laboratorios'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.medication_outlined, size: 15),
SizedBox(width: 6),
Text('Tratamiento'),
],
),
),
Tab(
height: 38,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_outlined, size: 15),
SizedBox(width: 6),
Text('Próxima cita'),
],
),
),
],
),
),
Expanded(
child: Observer(
builder: (_) => TabBarView(
controller: _tabController,
children: [
// 1. Consultas
_buildClinicalTextArea(
name: 'mot_cons',
controller: _motConsController,
focusNode: _motConsFocusNode,
hintText: 'Registro de consultas clínicas, motivo y evolución...',
dateLabel: s.fecUltcon.isNotEmpty ? 'Última consulta: ${s.fecUltcon}' : null,
onInsertDate: () {
_insertDateAtCursor(
controller: _motConsController,
focusNode: _motConsFocusNode,
onUpdateStore: (v) => s.motCons = v,
);
},
onChanged: (v) => s.motCons = v,
),
// 2. Hist. enfermedad actual
_buildClinicalTextArea(
name: 'his_enf_act',
controller: _hisEnfActController,
focusNode: _hisEnfActFocusNode,
hintText: 'Historia y cronología de la enfermedad actual...',
onInsertDate: () {
_insertDateAtCursor(
controller: _hisEnfActController,
focusNode: _hisEnfActFocusNode,
onUpdateStore: (v) => s.hisEnfAct = v,
);
},
onChanged: (v) => s.hisEnfAct = v,
),
// 3. Antec. patológicos
_buildAntecedentesTab(s),
// 4. Diagnósticos
_buildDiagnosticosTab(s),
// 5. Laboratorios
_buildClinicalTextArea(
name: 'dat_lab',
initialValue: s.datLab,
hintText: 'Resultados de laboratorio, hemogramas, analíticas...',
onChanged: (v) => s.datLab = v,
),
// 6. Tratamiento
_buildClinicalTextArea(
name: 'tratamiento',
initialValue: s.tratamiento,
hintText: 'Plan terapéutico, recetas, dosis y recomendaciones...',
onChanged: (v) => s.tratamiento = v,
),
// 7. Próxima cita
_buildProximaCitaTab(s),
],
),
),
),
],
);
}
}
@@ -0,0 +1,487 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../../../core/theme/app_colors.dart';
import '../store/patient_store.dart';
class PatientDemographicsForm extends StatelessWidget {
/// Altura estricta y compartida para todos los elementos de la ficha demográfica:
/// los 8 campos de texto, las 3 secciones de toggle (sexo, seguro, estado civil)
/// y el botón Guardar Paciente siempre tendrán exactamente la misma altura.
static const double unifiedRowHeight = 46.0;
final PatientStore store;
final GlobalKey<FormBuilderState> formKey;
final VoidCallback onSave;
const PatientDemographicsForm({
super.key,
required this.store,
required this.formKey,
required this.onSave,
});
InputDecoration _cleanDecoration({
required String label,
IconData? icon,
bool isRequired = false,
String? hintText,
bool readOnly = false,
}) {
return InputDecoration(
labelText: isRequired ? '$label *' : label,
hintText: hintText,
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.auto,
filled: true,
fillColor: readOnly ? const Color(0xFFF1F5F9) : Colors.white,
prefixIcon: icon != null
? Icon(icon, size: 16, color: isRequired ? AppColors.primary : AppColors.textMuted)
: null,
prefixIconConstraints: const BoxConstraints(minWidth: 34, minHeight: 34),
contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 14),
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),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
),
labelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isRequired ? AppColors.primary : AppColors.textSecondary,
),
hintStyle: const TextStyle(fontSize: 11.5, color: Color(0xFF94A3B8)),
);
}
Widget _buildCompactSelector({
required BuildContext context,
required String title,
required IconData titleIcon,
required Widget child,
bool isRequired = false,
}) {
return Container(
height: unifiedRowHeight,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(titleIcon, size: 14, color: isRequired ? AppColors.primary : AppColors.textMuted),
const SizedBox(width: 4),
Text(
isRequired ? '$title *' : title,
style: TextStyle(
fontSize: 11.0,
fontWeight: FontWeight.w700,
color: isRequired ? AppColors.primary : AppColors.textPrimary,
),
),
],
),
const SizedBox(width: 4),
Flexible(
child: Align(
alignment: Alignment.centerRight,
child: child,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final s = store;
final cedulaFormatter = MaskTextInputFormatter(
mask: '###-#######-#',
filter: {'#': RegExp(r'[0-9]')},
);
final telefonoFormatter = MaskTextInputFormatter(
mask: '###-###-####',
filter: {'#': RegExp(r'[0-9]')},
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Color(0xFFE2E8F0), width: 1.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sub-bloque 1 (Columna 1): Fecha de reg, Nombre, Dirección, Ocupación
Expanded(
flex: 7,
child: Column(
children: [
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'fec_reg',
initialValue: s.fecReg,
readOnly: true,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
decoration: _cleanDecoration(
label: 'Fecha de Registro',
icon: Icons.calendar_today_rounded,
readOnly: true,
),
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'nombre',
initialValue: s.nombre,
textCapitalization: TextCapitalization.words,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
decoration: _cleanDecoration(
label: 'Nombre',
icon: Icons.person_outline_rounded,
isRequired: true,
hintText: 'Ej. Juan Alberto',
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'Requerido'),
FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'),
]),
onChanged: (v) => s.nombre = v ?? '',
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'direccion',
initialValue: s.direccion,
style: const TextStyle(fontSize: 12),
decoration: _cleanDecoration(
label: 'Dirección',
icon: Icons.location_on_outlined,
hintText: 'Calle, número o sector',
),
onChanged: (v) => s.direccion = v ?? '',
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'ocupacion',
initialValue: s.ocupacion,
style: const TextStyle(fontSize: 12),
decoration: _cleanDecoration(
label: 'Ocupación',
icon: Icons.work_outline_rounded,
hintText: 'Profesión u oficio',
),
onChanged: (v) => s.ocupacion = v ?? '',
),
),
],
),
),
const SizedBox(width: 12),
// Sub-bloque 2 (Columna 2): Cédula o Pasaporte, Apellido, Teléfono, Edad
Expanded(
flex: 7,
child: Column(
children: [
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'cedula',
initialValue: s.cedula,
inputFormatters: [cedulaFormatter],
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
decoration: _cleanDecoration(
label: 'Cédula o Pasaporte',
icon: Icons.badge_outlined,
hintText: '001-0000000-0',
),
onChanged: (v) => s.cedula = v ?? '',
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'apellido',
initialValue: s.apellido,
textCapitalization: TextCapitalization.words,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
decoration: _cleanDecoration(
label: 'Apellido',
icon: Icons.badge_rounded,
isRequired: true,
hintText: 'Ej. Pérez Gómez',
),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: 'Requerido'),
FormBuilderValidators.minLength(2, errorText: 'Mínimo 2 letras'),
]),
onChanged: (v) => s.apellido = v ?? '',
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'telefono',
initialValue: s.telefono,
inputFormatters: [telefonoFormatter],
keyboardType: TextInputType.phone,
style: const TextStyle(fontSize: 12),
decoration: _cleanDecoration(
label: 'Teléfono',
icon: Icons.phone_outlined,
hintText: '809-555-0101',
),
onChanged: (v) => s.telefono = v ?? '',
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
child: FormBuilderTextField(
name: 'edad',
initialValue: s.edad,
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12),
decoration: _cleanDecoration(
label: 'Edad',
icon: Icons.cake_outlined,
hintText: 'Años',
),
onChanged: (v) => s.edad = v ?? '',
),
),
],
),
),
const SizedBox(width: 12),
// Sub-bloque 3 (Columna 3): Sexo, Asegurado, Estado Civil, Botón Guardar
Expanded(
flex: 10,
child: Column(
children: [
_buildCompactSelector(
context: context,
title: 'Sexo',
titleIcon: Icons.wc_rounded,
isRequired: true,
child: FormBuilderField<String>(
name: 'sexo',
initialValue: s.sexo,
validator: FormBuilderValidators.required(errorText: 'Requerido'),
builder: (FormFieldState<String> field) {
final currentVal = field.value ?? s.sexo;
final initialIndex = currentVal.toLowerCase().contains('fem') ? 1 : 0;
return ToggleSwitch(
key: ValueKey('sexo_$currentVal'),
customWidths: const [108.0, 108.0],
minHeight: 28.0,
cornerRadius: 6.0,
radiusStyle: true,
fontSize: 10.5,
iconSize: 13.0,
centerText: true,
initialLabelIndex: initialIndex,
totalSwitches: 2,
labels: const ['Masculino', 'Femenino'],
icons: const [Icons.male_rounded, Icons.female_rounded],
activeBgColor: const [AppColors.primary],
activeFgColor: Colors.white,
inactiveBgColor: const Color(0xFFE2E8F0),
inactiveFgColor: AppColors.textSecondary,
animate: true,
curve: Curves.easeInOut,
onToggle: (index) {
final val = (index == 1) ? 'Femenino' : 'Masculino';
field.didChange(val);
s.sexo = val;
},
);
},
),
),
const SizedBox(height: 8),
_buildCompactSelector(
context: context,
title: 'Seguro',
titleIcon: Icons.health_and_safety_outlined,
isRequired: true,
child: FormBuilderField<String>(
name: 'seguro',
initialValue: s.seguro,
validator: FormBuilderValidators.required(errorText: 'Requerido'),
builder: (FormFieldState<String> field) {
final currentVal = field.value ?? s.seguro;
final cleanVal = currentVal.trim().toLowerCase();
final isNo = cleanVal == 'no' || cleanVal.contains('sin seguro') || cleanVal.contains('no asegurado');
final initialIndex = isNo ? 1 : 0;
return ToggleSwitch(
key: ValueKey('seguro_$currentVal'),
customWidths: const [116.0, 116.0],
minHeight: 28.0,
cornerRadius: 6.0,
radiusStyle: true,
fontSize: 10.5,
iconSize: 12.0,
centerText: true,
initialLabelIndex: initialIndex,
totalSwitches: 2,
labels: const ['Con Seguro', 'Sin Seguro'],
icons: const [Icons.check_rounded, Icons.close_rounded],
activeBgColor: const [AppColors.primary],
activeFgColor: Colors.white,
inactiveBgColor: const Color(0xFFE2E8F0),
inactiveFgColor: AppColors.textSecondary,
animate: true,
curve: Curves.easeInOut,
onToggle: (index) {
final val = (index == 1) ? 'Sin Seguro' : 'Con Seguro';
field.didChange(val);
s.seguro = val;
},
);
},
),
),
const SizedBox(height: 8),
_buildCompactSelector(
context: context,
title: 'Estado Civil',
titleIcon: Icons.favorite_border_rounded,
child: FormBuilderField<String>(
name: 'est_civil',
initialValue: s.estCivil,
builder: (FormFieldState<String> field) {
const options = ['Casado', 'Soltero', 'Otros'];
final currentVal = field.value ?? s.estCivil;
int initialIndex = 1;
if (currentVal.toLowerCase().contains('casad')) {
initialIndex = 0;
} else if (currentVal.toLowerCase().contains('otro')) {
initialIndex = 2;
}
return ToggleSwitch(
key: ValueKey('est_civil_$currentVal'),
customWidths: const [86.0, 90.0, 76.0],
minHeight: 28.0,
cornerRadius: 6.0,
radiusStyle: true,
fontSize: 10.5,
iconSize: 11.5,
centerText: true,
initialLabelIndex: initialIndex,
totalSwitches: 3,
labels: options,
icons: const [Icons.people_alt_outlined, Icons.person_outline_rounded, Icons.more_horiz_rounded],
activeBgColor: const [AppColors.primary],
activeFgColor: Colors.white,
inactiveBgColor: const Color(0xFFE2E8F0),
inactiveFgColor: AppColors.textSecondary,
animate: true,
curve: Curves.easeInOut,
onToggle: (index) {
final selected = options[index ?? 1];
field.didChange(selected);
s.estCivil = selected;
},
);
},
),
),
const SizedBox(height: 8),
SizedBox(
height: unifiedRowHeight,
width: double.infinity,
child: Observer(
builder: (_) => ElevatedButton(
onPressed: s.isSaving ? null : onSave,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00A65A),
foregroundColor: Colors.white,
elevation: 1,
shadowColor: const Color(0xFF00A65A).withValues(alpha: 0.35),
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: s.isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline_rounded, size: 16),
const SizedBox(width: 6),
Text(
s.estadoEspera == 'en_consulta'
? 'Finalizar Consulta'
: (s.isEditing ? 'Guardar Cambios' : 'Guardar Paciente'),
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
],
),
),
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_colors.dart';
import '../store/patient_store.dart';
class SecretaryAppointmentSection extends StatelessWidget {
final PatientStore store;
const SecretaryAppointmentSection({
super.key,
required this.store,
});
void _setDate(DateTime date) {
store.fecProxcita = DateFormat('dd/MM/yyyy').format(date);
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Observer(
builder: (_) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Título de la sección
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00A65A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.event_available_rounded,
color: Color(0xFF00A65A),
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gestión de Citas y Turno del Paciente',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
SizedBox(height: 2),
Text(
'Coordine la próxima visita o turno del paciente en el consultorio',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
),
],
),
const SizedBox(height: 18),
// 2. Estado actual de la cita
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Icon(
store.fecProxcita.isNotEmpty
? Icons.check_circle_rounded
: Icons.info_outline_rounded,
color: store.fecProxcita.isNotEmpty
? const Color(0xFF00A65A)
: AppColors.textMuted,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store.fecProxcita.isNotEmpty
? 'Próxima Cita Programada: ${store.fecProxcita}'
: 'Sin fecha de cita programada',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: store.fecProxcita.isNotEmpty
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
if (store.diasParaProximaCita != null) ...[
const SizedBox(height: 2),
Text(
store.diasParaProximaCita! < 0
? 'La cita programada venció hace ${store.diasParaProximaCita!.abs()} días.'
: (store.diasParaProximaCita! == 0
? '¡Cita pautada para el día de HOY!'
: 'Faltan ${store.diasParaProximaCita} días para la cita.'),
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: store.diasParaProximaCita! < 0
? AppColors.error
: (store.diasParaProximaCita! == 0
? const Color(0xFFE65100)
: const Color(0xFF00A65A)),
),
),
],
],
),
),
if (store.fecProxcita.isNotEmpty)
TextButton.icon(
onPressed: () => store.fecProxcita = '',
icon: const Icon(Icons.clear_rounded, size: 16, color: AppColors.error),
label: const Text(
'Quitar Cita',
style: TextStyle(color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
),
const SizedBox(height: 16),
// 3. Botones para selección de fecha
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: now.add(const Duration(days: 7)),
firstDate: now.subtract(const Duration(days: 30)),
lastDate: now.add(const Duration(days: 365 * 3)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF00A65A),
onPrimary: Colors.white,
surface: Colors.white,
),
),
child: child!,
);
},
);
if (picked != null) {
_setDate(picked);
}
},
icon: const Icon(Icons.calendar_month_rounded, size: 16),
label: const Text('Elegir en Calendario', style: 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,
),
),
OutlinedButton(
onPressed: () => _setDate(now),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: Color(0xFFCBD5E1)),
),
child: const Text('Para Hoy', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)),
),
OutlinedButton(
onPressed: () => _setDate(now.add(const Duration(days: 1))),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: Color(0xFFCBD5E1)),
),
child: const Text('Mañana', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)),
),
OutlinedButton(
onPressed: () => _setDate(now.add(const Duration(days: 7))),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: Color(0xFFCBD5E1)),
),
child: const Text('En 7 días', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)),
),
OutlinedButton(
onPressed: () => _setDate(now.add(const Duration(days: 15))),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: Color(0xFFCBD5E1)),
),
child: const Text('En 15 días', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)),
),
OutlinedButton(
onPressed: () => _setDate(now.add(const Duration(days: 30))),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: const BorderSide(color: Color(0xFFCBD5E1)),
),
child: const Text('En 1 mes', style: TextStyle(fontSize: 12, color: AppColors.textPrimary)),
),
],
),
const SizedBox(height: 18),
// 4. Nota de Confidencialidad
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_outlined, color: Color(0xFF2563EB), size: 18),
SizedBox(width: 8),
Expanded(
child: Text(
'Privacidad de Datos: Los diagnósticos, notas de consulta y prescripciones médicas son de gestión exclusiva del médico tratante y se mantienen protegidos.',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF1E40AF),
height: 1.35,
),
),
),
],
),
),
],
),
);
},
);
}
}
@@ -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,
),
),
],
);
}
}
@@ -0,0 +1,416 @@
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;
@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();
}
void _insertBullet() {
final text = _prescriptionController.text;
final selection = _prescriptionController.selection;
final bullet = text.isEmpty || text.endsWith('\n') ? '• ' : '\n• ';
int start = selection.start;
int end = selection.end;
if (start < 0 || end < 0) {
start = text.length;
end = text.length;
}
final newText = text.replaceRange(start, end, bullet);
final newOffset = start + bullet.length;
_prescriptionController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newOffset),
);
_prescriptionFocusNode.requestFocus();
}
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),
// Barra de herramientas de redacción
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Medicamentos y Posología:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
TextButton.icon(
onPressed: _insertBullet,
icon: const Icon(Icons.format_list_bulleted_rounded, size: 16),
label: const Text('Insertar viñeta (•)', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 6),
// Lienzo de prescripción
TextField(
controller: _prescriptionController,
focusNode: _prescriptionFocusNode,
maxLines: 12,
minLines: 8,
style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: '1. Nombre del medicamento, concentración\n - Dosis y frecuencia (ej. Tomar 1 tableta cada 8 horas por 7 días)\n - Indicaciones especiales (con comidas, en ayunas, etc.)\n\n2. Siguiente medicamento...',
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,299 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class PrescriptionPdfService {
/// Genera el documento PDF de la receta médica en formato Media Carta Horizontal
/// (8.5 x 5.5 pulgadas / 612 x 396 pt).
static Future<Uint8List> buildPrescriptionPdf({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String patientName,
required String prescriptionText,
DateTime? date,
pw.Font? fontRegular,
pw.Font? fontBold,
pw.Font? fontTitle,
String? logoSvg,
}) async {
final pdf = pw.Document(title: 'Receta Médica - $patientName');
final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now());
// Formato exacto e idéntico a AlfaMedical:
// Media Carta Horizontal (8.5 x 5.5 pulgadas / 612 x 396 pt)
// Márgenes idénticos a AlfaMedical (ReportLab):
// leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18.
const pageFormat = PdfPageFormat(
8.5 * PdfPageFormat.inch,
5.5 * PdfPageFormat.inch,
marginLeft: 72,
marginRight: 72,
marginTop: 18,
marginBottom: 18,
);
// Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet
pw.Font regular = fontRegular ?? pw.Font.helvetica();
pw.Font bold = fontBold ?? pw.Font.helveticaBold();
pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic();
if (fontRegular == null || fontBold == null || fontTitle == null) {
try {
regular = await PdfGoogleFonts.robotoRegular();
bold = await PdfGoogleFonts.robotoBold();
titleFont = await PdfGoogleFonts.playfairDisplayItalic();
} catch (_) {
// Fallback garantizado sin crash si está offline o en test
regular = pw.Font.helvetica();
bold = pw.Font.helveticaBold();
titleFont = pw.Font.timesBoldItalic();
}
}
String? svgContent = logoSvg;
if (svgContent == null) {
try {
svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg');
} catch (_) {
svgContent = null;
}
}
pdf.addPage(
pw.Page(
pageFormat: pageFormat,
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 1. Cabecera con Nombre del Doctor y Badge Superior de Hidalyx Med
pw.Stack(
children: [
// Nombre y especialidad del doctor centrado
pw.Align(
alignment: pw.Alignment.center,
child: pw.Column(
children: [
pw.Text(
doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa',
style: pw.TextStyle(
font: titleFont,
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('1E293B'),
),
),
pw.SizedBox(height: 3),
if (specialty.isNotEmpty)
pw.Text(
specialty.toUpperCase(),
style: pw.TextStyle(
font: bold,
fontSize: 10.5,
letterSpacing: 0.5,
color: PdfColor.fromHex('0F172A'),
),
),
if (subspecialty.isNotEmpty) ...[
pw.SizedBox(height: 2),
pw.Text(
subspecialty.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 8.5,
color: PdfColor.fromHex('475569'),
),
),
],
pw.SizedBox(height: 3),
pw.Text(
'Fecha: $formattedDate',
style: pw.TextStyle(
font: regular,
fontSize: 9.5,
color: PdfColor.fromHex('334155'),
),
),
],
),
),
// Badge sutil de Hidalyx Med en la esquina superior derecha
pw.Positioned(
top: 0,
right: 0,
child: pw.Row(
mainAxisSize: pw.MainAxisSize.min,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (svgContent != null && svgContent.isNotEmpty) ...[
pw.Opacity(
opacity: 0.6,
child: pw.SvgImage(
svg: svgContent,
width: 7.5,
height: 7.5,
),
),
pw.SizedBox(width: 3),
],
pw.Text(
'hidalyx.com',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
],
),
),
],
),
pw.SizedBox(height: 6),
// Línea divisoria decorativa del membrete
pw.Container(
height: 1,
color: PdfColor.fromHex('CBD5E1'),
),
pw.SizedBox(height: 8),
// 2. Encabezado de la Prescripción: Símbolo Rx y Paciente
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColor.fromHex('E2E8F0'),
borderRadius: pw.BorderRadius.circular(4),
),
child: pw.Text(
'Rx',
style: pw.TextStyle(
font: bold,
fontSize: 12,
color: PdfColor.fromHex('00A65A'),
),
),
),
pw.SizedBox(width: 8),
pw.Expanded(
child: pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: 'Paciente: ',
style: pw.TextStyle(
font: bold,
fontSize: 11,
color: PdfColor.fromHex('1E293B'),
),
),
pw.TextSpan(
text: patientName.isNotEmpty ? patientName : '___________________________',
style: pw.TextStyle(
font: regular,
fontSize: 11,
color: PdfColor.fromHex('0F172A'),
),
),
],
),
),
),
],
),
pw.SizedBox(height: 8),
// 3. Contenido de la Receta / Prescripción Médica
pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 4),
child: pw.Text(
prescriptionText.trim().isNotEmpty
? prescriptionText.trim()
: 'Sin prescripciones anotadas.',
style: pw.TextStyle(
font: regular,
fontSize: 10,
lineSpacing: 3,
color: PdfColor.fromHex('1E293B'),
),
),
),
),
pw.SizedBox(height: 6),
// 4. Pie de página con Contacto del Consultorio
if (generalContact.isNotEmpty) ...[
pw.Container(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
generalContact.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 6.8,
color: PdfColor.fromHex('64748B'),
lineSpacing: 1.5,
),
),
),
],
pw.SizedBox(height: 3),
// Confirmación institucional
pw.Center(
child: pw.Text(
'Documento médico emitido electrónicamente',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
),
],
);
},
),
);
return pdf.save();
}
/// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF.
static Future<void> printPrescription({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String patientName,
required String prescriptionText,
DateTime? date,
}) async {
final pdfBytes = await buildPrescriptionPdf(
doctorName: doctorName,
specialty: specialty,
subspecialty: subspecialty,
generalContact: generalContact,
patientName: patientName,
prescriptionText: prescriptionText,
date: date,
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdfBytes,
name: 'Receta_${patientName.replaceAll(' ', '_')}',
);
}
}
@@ -0,0 +1,322 @@
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/department_referral_pdf_service.dart';
class DepartmentReferralModalView extends StatefulWidget {
final String patientName;
final ConfigStore? configStore;
const DepartmentReferralModalView({
super.key,
required this.patientName,
this.configStore,
});
@override
State<DepartmentReferralModalView> createState() => _DepartmentReferralModalViewState();
}
class _DepartmentReferralModalViewState extends State<DepartmentReferralModalView> {
late final TextEditingController _referralController;
late final ConfigStore _configStore;
bool _isPrinting = false;
String? _statusMessage;
bool _hasError = false;
@override
void initState() {
super.initState();
// Plantilla exacta por defecto de AlfaMedical (index.py líneas 1530-1553)
final initialContent = [
'Paciente: ${widget.patientName}',
'',
'Referido al departamento de anestesiologia',
'Plan: Realizar evaluación pre-anestesica',
'',
'Para realizar: ',
'',
'',
'Suite 408, 4ta planta. ',
'Gracias.',
].join('\n');
_referralController = TextEditingController(text: initialContent);
_configStore = widget.configStore ?? ConfigStore();
_configStore.loadConfig();
}
@override
void dispose() {
_referralController.dispose();
super.dispose();
}
Future<void> _handlePrint() async {
final text = _referralController.text.trim();
if (text.isEmpty) {
setState(() {
_statusMessage = 'Debe redactar el contenido del referimiento.';
_hasError = true;
});
return;
}
setState(() {
_isPrinting = true;
_statusMessage = 'Generando documento de referimiento a departamento...';
_hasError = false;
});
try {
await DepartmentReferralPdfService.printDepartmentReferral(
doctorName: _configStore.nombreDoctor,
specialty: _configStore.especialidad,
subspecialty: _configStore.descripEspecialidad,
generalContact: _configStore.descripGeneral,
referralText: text,
);
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Referimiento generado con éxito.';
_hasError = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Error al generar el referimiento: $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: 'Referir a Departamento',
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.apartment_rounded,
color: Color(0xFF00A65A),
size: 20,
),
),
subtitle: const Text(
'Emisión oficial de referimiento médico a departamento o servicio 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 el referimiento a departamento'),
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 Referimiento',
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
Container(
padding: const EdgeInsets.all(14),
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),
const Text(
'Detalle del Referimiento a Departamento:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
// Área de texto multilínea idéntica a ScrolledText de AlfaMedical
TextField(
controller: _referralController,
maxLines: 12,
minLines: 9,
style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Redacte el referimiento a departamento...',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,321 @@
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/doctor_referral_pdf_service.dart';
class DoctorReferralModalView extends StatefulWidget {
final String patientName;
final ConfigStore? configStore;
const DoctorReferralModalView({
super.key,
required this.patientName,
this.configStore,
});
@override
State<DoctorReferralModalView> createState() => _DoctorReferralModalViewState();
}
class _DoctorReferralModalViewState extends State<DoctorReferralModalView> {
late final TextEditingController _referralController;
late final ConfigStore _configStore;
bool _isPrinting = false;
String? _statusMessage;
bool _hasError = false;
@override
void initState() {
super.initState();
// Plantilla exacta por defecto de AlfaMedical (index.py líneas 1404-1419)
final initialContent = [
'Paciente: ${widget.patientName}',
'',
'Referido a Doctor (a) Miguelina Pichardo',
'Medicina Interna',
'',
'Plan: Realizar evaluación cardiovascular para realizar: ',
'',
'',
'Gracias.',
].join('\n');
_referralController = TextEditingController(text: initialContent);
_configStore = widget.configStore ?? ConfigStore();
_configStore.loadConfig();
}
@override
void dispose() {
_referralController.dispose();
super.dispose();
}
Future<void> _handlePrint() async {
final text = _referralController.text.trim();
if (text.isEmpty) {
setState(() {
_statusMessage = 'Debe redactar el contenido del referimiento.';
_hasError = true;
});
return;
}
setState(() {
_isPrinting = true;
_statusMessage = 'Generando documento de referimiento a doctor(a)...';
_hasError = false;
});
try {
await DoctorReferralPdfService.printDoctorReferral(
doctorName: _configStore.nombreDoctor,
specialty: _configStore.especialidad,
subspecialty: _configStore.descripEspecialidad,
generalContact: _configStore.descripGeneral,
referralText: text,
);
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Referimiento generado con éxito.';
_hasError = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isPrinting = false;
_statusMessage = 'Error al generar el referimiento: $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: 'Referir a Doctor (a)',
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.person_pin_circle_rounded,
color: Color(0xFF00A65A),
size: 20,
),
),
subtitle: const Text(
'Emisión oficial de interconsulta médica dirigida a un especialista',
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 el referimiento a doctor(a)'),
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 Referimiento',
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
Container(
padding: const EdgeInsets.all(14),
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),
const Text(
'Detalle de la Interconsulta / Referimiento:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
// Área de texto multilínea idéntica a ScrolledText de AlfaMedical
TextField(
controller: _referralController,
maxLines: 12,
minLines: 9,
style: const TextStyle(fontSize: 13.5, height: 1.5, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Redacte el referimiento médico...',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,245 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class DepartmentReferralPdfService {
/// Genera el documento PDF de Referimiento a Departamento en formato Media Carta Horizontal
/// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical.
static Future<Uint8List> buildDepartmentReferralPdf({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String referralText,
DateTime? date,
pw.Font? fontRegular,
pw.Font? fontBold,
pw.Font? fontTitle,
String? logoSvg,
}) async {
final pdf = pw.Document(title: 'Referir a Departamento');
final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now());
// Formato exacto e idéntico a AlfaMedical (ReportLab):
// pagesize = (8.5*inch, 5.5*inch)
// leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18.
const pageFormat = PdfPageFormat(
8.5 * PdfPageFormat.inch,
5.5 * PdfPageFormat.inch,
marginLeft: 72,
marginRight: 72,
marginTop: 18,
marginBottom: 18,
);
// Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet
pw.Font regular = fontRegular ?? pw.Font.helvetica();
pw.Font bold = fontBold ?? pw.Font.helveticaBold();
pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic();
if (fontRegular == null || fontBold == null || fontTitle == null) {
try {
regular = await PdfGoogleFonts.robotoRegular();
bold = await PdfGoogleFonts.robotoBold();
titleFont = await PdfGoogleFonts.playfairDisplayItalic();
} catch (_) {
regular = pw.Font.helvetica();
bold = pw.Font.helveticaBold();
titleFont = pw.Font.timesBoldItalic();
}
}
String? svgContent = logoSvg;
if (svgContent == null) {
try {
svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg');
} catch (_) {
svgContent = null;
}
}
pdf.addPage(
pw.Page(
pageFormat: pageFormat,
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med
pw.Stack(
children: [
pw.Align(
alignment: pw.Alignment.center,
child: pw.Column(
children: [
pw.Text(
doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa',
style: pw.TextStyle(
font: titleFont,
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('1E293B'),
),
),
pw.SizedBox(height: 3),
if (specialty.isNotEmpty)
pw.Text(
specialty.toUpperCase(),
style: pw.TextStyle(
font: bold,
fontSize: 10.5,
letterSpacing: 0.5,
color: PdfColor.fromHex('0F172A'),
),
),
if (subspecialty.isNotEmpty) ...[
pw.SizedBox(height: 2),
pw.Text(
subspecialty.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 8.5,
color: PdfColor.fromHex('475569'),
),
),
],
pw.SizedBox(height: 3),
pw.Text(
'Fecha: $formattedDate',
style: pw.TextStyle(
font: regular,
fontSize: 9.5,
color: PdfColor.fromHex('334155'),
),
),
],
),
),
// Badge sutil de Hidalyx Med en la esquina superior derecha
pw.Positioned(
top: 0,
right: 0,
child: pw.Row(
mainAxisSize: pw.MainAxisSize.min,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (svgContent != null && svgContent.isNotEmpty) ...[
pw.Opacity(
opacity: 0.6,
child: pw.SvgImage(
svg: svgContent,
width: 7.5,
height: 7.5,
),
),
pw.SizedBox(width: 3),
],
pw.Text(
'hidalyx.com',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
],
),
),
],
),
pw.SizedBox(height: 6),
// Línea divisoria decorativa del membrete
pw.Container(
height: 1,
color: PdfColor.fromHex('CBD5E1'),
),
pw.SizedBox(height: 8),
// 2. Contenido del Referimiento (Idéntico a AlfaMedical)
pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 4),
child: pw.Text(
referralText.trim().isNotEmpty
? referralText.trim()
: 'Sin detalles de referimiento.',
style: pw.TextStyle(
font: regular,
fontSize: 10.5,
lineSpacing: 3.5,
color: PdfColor.fromHex('1E293B'),
),
),
),
),
pw.SizedBox(height: 6),
// 3. Pie de página con Contacto del Consultorio
if (generalContact.isNotEmpty) ...[
pw.Container(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
generalContact.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 6.8,
color: PdfColor.fromHex('64748B'),
lineSpacing: 1.5,
),
),
),
],
pw.SizedBox(height: 3),
// Confirmación institucional
pw.Center(
child: pw.Text(
'Documento médico emitido electrónicamente',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
),
],
);
},
),
);
return pdf.save();
}
/// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF.
static Future<void> printDepartmentReferral({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String referralText,
DateTime? date,
}) async {
final pdfBytes = await buildDepartmentReferralPdf(
doctorName: doctorName,
specialty: specialty,
subspecialty: subspecialty,
generalContact: generalContact,
referralText: referralText,
date: date,
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdfBytes,
name: 'Referimiento_Departamento',
);
}
}
@@ -0,0 +1,245 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class DoctorReferralPdfService {
/// Genera el documento PDF de Referimiento a Doctor (a) en formato Media Carta Horizontal
/// (8.5 x 5.5 pulgadas / 612 x 396 pt), idéntico a AlfaMedical.
static Future<Uint8List> buildDoctorReferralPdf({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String referralText,
DateTime? date,
pw.Font? fontRegular,
pw.Font? fontBold,
pw.Font? fontTitle,
String? logoSvg,
}) async {
final pdf = pw.Document(title: 'Referir a Doctor (a)');
final formattedDate = DateFormat('dd/MM/yyyy').format(date ?? DateTime.now());
// Formato exacto e idéntico a AlfaMedical (ReportLab):
// pagesize = (8.5*inch, 5.5*inch)
// leftMargin = 72, rightMargin = 72, topMargin = 18, bottomMargin = 18.
const pageFormat = PdfPageFormat(
8.5 * PdfPageFormat.inch,
5.5 * PdfPageFormat.inch,
marginLeft: 72,
marginRight: 72,
marginTop: 18,
marginBottom: 18,
);
// Tipografías estándar con fallback a fuentes base PDF si no hay conexión a internet
pw.Font regular = fontRegular ?? pw.Font.helvetica();
pw.Font bold = fontBold ?? pw.Font.helveticaBold();
pw.Font titleFont = fontTitle ?? pw.Font.timesBoldItalic();
if (fontRegular == null || fontBold == null || fontTitle == null) {
try {
regular = await PdfGoogleFonts.robotoRegular();
bold = await PdfGoogleFonts.robotoBold();
titleFont = await PdfGoogleFonts.playfairDisplayItalic();
} catch (_) {
regular = pw.Font.helvetica();
bold = pw.Font.helveticaBold();
titleFont = pw.Font.timesBoldItalic();
}
}
String? svgContent = logoSvg;
if (svgContent == null) {
try {
svgContent = await rootBundle.loadString('assets/images/hidalyx_logo_blue.svg');
} catch (_) {
svgContent = null;
}
}
pdf.addPage(
pw.Page(
pageFormat: pageFormat,
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// 1. Cabecera con Nombre del Doctor y Badge Sutil de Hidalyx Med
pw.Stack(
children: [
pw.Align(
alignment: pw.Alignment.center,
child: pw.Column(
children: [
pw.Text(
doctorName.isNotEmpty ? doctorName : 'Dr. Luis A. Castro Sosa',
style: pw.TextStyle(
font: titleFont,
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('1E293B'),
),
),
pw.SizedBox(height: 3),
if (specialty.isNotEmpty)
pw.Text(
specialty.toUpperCase(),
style: pw.TextStyle(
font: bold,
fontSize: 10.5,
letterSpacing: 0.5,
color: PdfColor.fromHex('0F172A'),
),
),
if (subspecialty.isNotEmpty) ...[
pw.SizedBox(height: 2),
pw.Text(
subspecialty.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 8.5,
color: PdfColor.fromHex('475569'),
),
),
],
pw.SizedBox(height: 3),
pw.Text(
'Fecha: $formattedDate',
style: pw.TextStyle(
font: regular,
fontSize: 9.5,
color: PdfColor.fromHex('334155'),
),
),
],
),
),
// Badge sutil de Hidalyx Med en la esquina superior derecha
pw.Positioned(
top: 0,
right: 0,
child: pw.Row(
mainAxisSize: pw.MainAxisSize.min,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (svgContent != null && svgContent.isNotEmpty) ...[
pw.Opacity(
opacity: 0.6,
child: pw.SvgImage(
svg: svgContent,
width: 7.5,
height: 7.5,
),
),
pw.SizedBox(width: 3),
],
pw.Text(
'hidalyx.com',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
],
),
),
],
),
pw.SizedBox(height: 6),
// Línea divisoria decorativa del membrete
pw.Container(
height: 1,
color: PdfColor.fromHex('CBD5E1'),
),
pw.SizedBox(height: 8),
// 2. Contenido del Referimiento (Idéntico a AlfaMedical con fontSize 13-14)
pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 4),
child: pw.Text(
referralText.trim().isNotEmpty
? referralText.trim()
: 'Sin detalles de referimiento.',
style: pw.TextStyle(
font: regular,
fontSize: 10.5,
lineSpacing: 3.5,
color: PdfColor.fromHex('1E293B'),
),
),
),
),
pw.SizedBox(height: 6),
// 3. Pie de página con Contacto del Consultorio
if (generalContact.isNotEmpty) ...[
pw.Container(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
generalContact.toUpperCase(),
textAlign: pw.TextAlign.center,
style: pw.TextStyle(
font: regular,
fontSize: 6.8,
color: PdfColor.fromHex('64748B'),
lineSpacing: 1.5,
),
),
),
],
pw.SizedBox(height: 3),
// Confirmación institucional
pw.Center(
child: pw.Text(
'Documento médico emitido electrónicamente',
style: pw.TextStyle(
font: regular,
fontSize: 6.0,
color: PdfColor.fromHex('94A3B8'),
),
),
),
],
);
},
),
);
return pdf.save();
}
/// Envía a imprimir directamente o abre el diálogo nativo de impresión/PDF.
static Future<void> printDoctorReferral({
required String doctorName,
required String specialty,
required String subspecialty,
required String generalContact,
required String referralText,
DateTime? date,
}) async {
final pdfBytes = await buildDoctorReferralPdf(
doctorName: doctorName,
specialty: specialty,
subspecialty: subspecialty,
generalContact: generalContact,
referralText: referralText,
date: date,
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdfBytes,
name: 'Referimiento_Doctor',
);
}
}