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