first commit
This commit is contained in:
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user