first commit

This commit is contained in:
=
2026-09-21 16:33:51 -04:00
commit a899479dc9
204 changed files with 21132 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
typedef TokenRefreshCallback = Future<String?> Function();
class ApiClient {
static const String baseUrl = 'https://api.hidalyx.com';
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
final Dio dio;
TokenRefreshCallback? onTokenRefresh;
ApiClient._internal({Dio? customDio})
: dio = customDio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
),
) {
_setupInterceptors();
}
ApiClient.withDio(this.dio) {
_setupInterceptors();
}
void _setupInterceptors() {
dio.interceptors.add(
QueuedInterceptorsWrapper(
onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
final path = options.path;
if (path.contains('/auth/login')) {
// NUNCA enviar Authorization header en el endpoint de login
options.headers.remove('Authorization');
}
return handler.next(options);
},
onError: (DioException err, ErrorInterceptorHandler handler) async {
final statusCode = err.response?.statusCode;
final path = err.requestOptions.path;
final isAuthEndpoint = path.contains('/auth/login') ||
path.contains('/auth/refresh') ||
path.contains('/auth/logout');
final isExpiredOrUnauthorized = statusCode == 401 ||
(statusCode == 403 &&
err.response?.data?.toString().contains('TOKEN_EXPIRED') == true);
if (isExpiredOrUnauthorized && !isAuthEndpoint && onTokenRefresh != null) {
try {
final newToken = await onTokenRefresh!();
if (newToken != null && newToken.isNotEmpty) {
setAuthToken(newToken);
final requestOptions = err.requestOptions;
requestOptions.headers['Authorization'] = 'Bearer $newToken';
final retryResponse = await dio.fetch(requestOptions);
return handler.resolve(retryResponse);
}
} catch (_) {
// Si el refresh falla, continuar el flujo del error normal
}
}
return handler.next(err);
},
),
);
}
void setAuthToken(String token) {
dio.options.headers['Authorization'] = 'Bearer $token';
}
void clearAuthToken() {
dio.options.headers.remove('Authorization');
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/views/login_view.dart';
import '../../features/dashboard/presentation/views/dashboard_view.dart';
class AppRouter {
static GoRouter getRouter([String initialLocation = '/login']) => GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginView(),
),
GoRoute(
path: '/',
name: 'dashboard',
builder: (context, state) => const DashboardView(),
),
],
);
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class AppColors {
// Color institucional exacto de Hidalyx (extraído del SVG oficial)
static const Color primary = Color(0xFF2251FF);
static const Color primaryDark = Color(0xFF143CD6);
static const Color primaryLight = Color(0xFF5A80FF);
static const Color primaryContainer = Color(0xFFEEF2FF);
// Acentos médicos complementarios
static const Color secondary = Color(0xFF0EA5E9);
static const Color teal = Color(0xFF00C9A7);
// Superficies y fondos
static const Color background = Color(0xFFF8FAFC);
static const Color surface = Color(0xFFFFFFFF);
static const Color cardBg = Color(0xFFFFFFFF);
// Bordes y divisores
static const Color border = Color(0xFFE2E8F0);
static const Color divider = Color(0xFFEDF2F7);
// Textos
static const Color textPrimary = Color(0xFF0F172A);
static const Color textSecondary = Color(0xFF64748B);
static const Color textMuted = Color(0xFF94A3B8);
// Estados
static const Color success = Color(0xFF10B981);
static const Color warning = Color(0xFFF59E0B);
static const Color error = Color(0xFFEF4444);
static const Color info = Color(0xFF2251FF);
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static const String fontFamily = 'Poppins';
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
fontFamily: fontFamily,
scaffoldBackgroundColor: AppColors.background,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary,
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: AppColors.surface,
error: AppColors.error,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontFamily: fontFamily,
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
cardTheme: CardThemeData(
color: AppColors.cardBg,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: AppColors.border),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.error),
),
labelStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 14,
color: AppColors.textSecondary,
),
hintStyle: const TextStyle(
fontFamily: fontFamily,
fontSize: 14,
color: AppColors.textMuted,
),
),
dividerTheme: const DividerThemeData(
color: AppColors.divider,
thickness: 1,
space: 1,
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_colors.dart';
/// Función estándar para abrir modales médicos en Hidalyx Med.
///
/// Cumple con la regla institucional estricta:
/// - barrierDismissible: false (NO se cierra haciendo clic en el exterior).
/// - Solamente se cierra mediante la tecla ESC o el botón (X) superior derecho.
Future<T?> showMedicalModal<T>({
required BuildContext context,
required WidgetBuilder builder,
bool useRootNavigator = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
useRootNavigator: useRootNavigator,
builder: builder,
);
}
/// Contenedor base estándar para todos los modales de Hidalyx Med.
///
/// Implementa:
/// 1. Cierre garantizado con tecla [ESC] a nivel hardware y de atajos.
/// 2. Botón con icono de cerrar (X) en la esquina superior derecha.
/// 3. Bloqueo de cierre al tocar fuera del diálogo.
class MedicalModalDialog extends StatefulWidget {
final Widget? titleIcon;
final String title;
final Widget? subtitle;
final Widget child;
final double maxWidth;
final double maxHeight;
final VoidCallback? onClose;
final Widget? bottomBar;
final List<Widget>? actions;
const MedicalModalDialog({
super.key,
this.titleIcon,
required this.title,
this.subtitle,
required this.child,
this.maxWidth = 740,
this.maxHeight = 620,
this.onClose,
this.bottomBar,
this.actions,
});
@override
State<MedicalModalDialog> createState() => _MedicalModalDialogState();
}
class _MedicalModalDialogState extends State<MedicalModalDialog> {
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleHardwareKey);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleHardwareKey);
super.dispose();
}
bool _handleHardwareKey(KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
_close();
return true;
}
return false;
}
void _close() {
if (!mounted) return;
if (widget.onClose != null) {
widget.onClose!();
} else {
Navigator.of(context).maybePop();
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Container(
constraints: BoxConstraints(
maxWidth: widget.maxWidth,
maxHeight: widget.maxHeight,
minWidth: 320,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Encabezado Superior con Botón (X) de Cierre
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: AppColors.border),
),
),
child: Row(
children: [
if (widget.titleIcon != null) ...[
widget.titleIcon!,
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 2),
widget.subtitle!,
],
],
),
),
if (widget.actions != null) ...[
...widget.actions!,
const SizedBox(width: 8),
],
// Botón oficial de cerrar (X) en esquina superior derecha
IconButton(
icon: const Icon(
Icons.close_rounded,
color: AppColors.textSecondary,
size: 20,
),
tooltip: 'Cerrar (Esc)',
splashRadius: 20,
onPressed: _close,
),
],
),
),
// Contenido Principal
Expanded(child: widget.child),
// Barra Inferior Informativa (Opcional)
if (widget.bottomBar != null) widget.bottomBar!,
],
),
),
),
);
}
}