Proyecto
Esta sección detalla la lógica interna y las funciones principales que permiten la operatividad de la aplicación Maroun en Flutter.
1. Función Principal y Clase Root
La ejecución de la aplicación comienza con la inicialización del motor de Flutter y la configuración del contenedor global.
Función main()
Es el punto de entrada al sistema. WidgetsFlutterBinding.ensureInitialized() prepara el motor interno antes de mostrar la interfaz, asegurando que los servicios nativos estén listos.
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyWebsite(),
);
}
}
Clase MyApp
Es el widget principal que configura la estructura global de la aplicación. Se define como un StatelessWidget debido a que su única responsabilidad es establecer la configuración inicial (como el tema, títulos y rutas) y no requiere manejar cambios de estado internos.
Dentro del método build(), se retorna un MaterialApp, que actúa como el contenedor base con diseño Material Design. En esta instancia se realizan las siguientes configuraciones clave:
- Desactivación del banner de debug: Se utiliza la propiedad
debugShowCheckedModeBanner: falsepara limpiar la interfaz visual durante el desarrollo. - Definición de la pantalla inicial: Mediante la propiedad
home, se estableceMyWebsite()como el punto de partida de la aplicación, donde reside la lógica principal de navegación y visualización.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyWebsite(),
);
}
}
MyWebsite
La clase MyWebsite representa la pantalla principal de la aplicación. Se define como un StatefulWidget porque necesita manejar cambios dinámicos durante la ejecución, como el cambio de pestañas, la carga de páginas y el estado de conexión.
@override
State<MyWebsite> createState() => _MyWebsiteState();
}
Aquí se indica que toda la lógica y el estado real estarán en _MyWebsiteState. Dentro de _MyWebsiteState se definen las variables que controlan el comportamiento general de la aplicación.
int currentIndex = 0;
bool isSceneLoading = false;
bool hasConnection = true;}}
currentIndex indica qué pestaña del menú inferior está activa. isSceneLoading determina si debe mostrarse el indicador visual de carga. hasConnection controla si se muestra la WebView o la pantalla de “sin conexión”.
También se define el controlador de la WebView:
InAppWebViewController? controller;
Este controlador permite cargar nuevas URLs manualmente cuando el usuario cambia de sección. El método initState() se ejecuta una sola vez cuando el widget se crea. Allí se define que la aplicación siempre inicia en la pestaña principal.
void initState() {
super.initState();
currentIndex = 0;
}
Luego se define la lógica que decide cuándo mostrar el loader:
bool _shouldShowLoader(int fromIndex, int toIndex) {
return fromIndex == 4 || toIndex == 4;
}
En este caso, el indicador de carga se activa cuando se entra o sale de la pestaña “Usuarios”. Las URLs de cada sección se almacenan como variables, lo que facilita el mantenimiento del código.
final String urlHome = "...";
final String urlUsuarios = "...";
Cuando el usuario toca un botón del menú inferior, se ejecuta _handleTabTap:
void _handleTabTap(int index) {
if (index == currentIndex) return;
final url = _getUrlByIndex(index);
setState(() {
currentIndex = index;
});
controller?.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
}
Este método: Evita recargar la misma pestaña. Obtiene la URL correspondiente. Actualiza el estado. Ordena a la WebView cargar la nueva página. Finalmente, _getUrlByIndex convierte el número de pestaña en su URL correspondiente:
String _getUrlByIndex(int index) {
switch (index) {
case 1: return urlContacto;
default: return urlHome;
}
}
Esto separa la lógica visual (índices del menú) de la lógica de navegación (URLs reales), haciendo el código más claro y mantenible.
Intercepción de Navegación
Este método se ejecuta cada vez que la WebView intenta abrir una nueva URL.
Future<NavigationActionPolicy> _handleUrlLoading(
InAppWebViewController controller,
NavigationAction navigationAction,
) async {
Primero se obtiene la URL solicitada:
final uri = navigationAction.request.url;
if (uri == null) return NavigationActionPolicy.CANCEL;
Si por alguna razón la URL es nula, se cancela la navegación. Luego se verifica si la URL pertenece al dominio permitido:
if (uri.host == allowedHost || uri.host.endsWith(".$allowedHost")) {
return NavigationActionPolicy.ALLOW;
}
Si la URL pertenece al dominio configurado (allowedHost), se permite la navegación dentro de la aplicación. Si la URL no pertenece al dominio permitido, se intenta abrir en el navegador externo del dispositivo:
await launchUrl(uri, mode: LaunchMode.externalApplication);
Si ocurre un error al intentar abrirla, se muestra un mensaje mediante un SnackBar. Finalmente, la navegación interna se cancela:
return NavigationActionPolicy.CANCEL;
Manejo de Errores de Carga
Este método se ejecuta cuando ocurre un error al cargar una página en la WebView.
void _handleLoadError(
InAppWebViewController controller,
Uri? url,
int code,
String message,
)
Cuando se detecta un error:
setState(() {
hasConnection = false;
isSceneLoading = false;
});
Se actualiza el estado para: • Indicar que no hay conexión. • Ocultar el loader.
Luego se muestra un mensaje al usuario: _showSnackBar('Error de conexión');
Este método permite que la aplicación reaccione visualmente ante fallos de red.
Manejo de Advertencias SSL
Este método se ejecuta cuando la WebView detecta un problema con el certificado SSL del sitio.
Future<ServerTrustAuthResponse> _handleSSLError(
InAppWebViewController controller,
URLAuthenticationChallenge challenge,
) async {
Actualmente, la aplicación decide continuar con la navegación:
return ServerTrustAuthResponse(
action: ServerTrustAuthResponseAction.PROCEED,
);
Esto significa que no bloquea la carga aunque exista una advertencia SSL. Es una decisión funcional que permite que la aplicación continúe operando, aunque en entornos de producción con mayores exigencias de seguridad podría requerir revisión.
Visualización de Mensajes al Usuario
Este método se encarga de mostrar mensajes breves en pantalla utilizando un SnackBar, que es una notificación temporal que aparece en la parte inferior de la aplicación.
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Primero se recibe un texto como parámetro (message). Este texto será el contenido que verá el usuario.
La condición:
if (!mounted) return;
verifica que el widget todavía esté activo en pantalla.
Si el widget ya fue eliminado del árbol de widgets (por ejemplo, la pantalla cambió), se evita ejecutar el código para prevenir errores. Luego se utiliza:
ScaffoldMessenger.of(context).showSnackBar(...)
ScaffoldMessenger es el encargado de mostrar mensajes temporales dentro de un Scaffold. Aquí se crea un SnackBar cuyo contenido es simplemente un Text(message).
Pantalla sin Conexión
Este método construye la interfaz que se muestra cuando la aplicación detecta un error de conexión. Su objetivo es reemplazar temporalmente la WebView por una pantalla informativa clara y permitir al usuario reintentar la carga.
Widget _noConnectionView() {
return Container(
color: const Color(0xFF2F3A56),
width: double.infinity,
height: double.infinity,
Se crea un Container que ocupa toda la pantalla (double.infinity) y se le asigna un color de fondo específico. Esto asegura que el usuario vea una pantalla completa de error y no contenido parcial.
Dentro del contenedor se centra el contenido:
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
Se utiliza un Column centrado vertical y horizontalmente para organizar los elementos uno debajo del otro. Primero se muestra un ícono que representa la falta de conexión:
Icon(
Icons.wifi_off,
color: Colors.white,
size: 64,
)
Luego se muestra un mensaje informativo:
Text(
'Sin conexión a Internet',
)
Este texto comunica claramente el problema al usuario. Después se incluye un botón para reintentar:
ElevatedButton(
onPressed: () {
setState(() {
hasConnection = true;
isSceneLoading = true;
});
controller?.loadUrl(
urlRequest: URLRequest(
url: WebUri(_getUrlByIndex(currentIndex)),
),
);
},
Interfaz Principal
El método build() es el motor principal de la interfaz; se encarga de construir y renderizar toda la jerarquía visual de la aplicación.
dart
@override
Widget build(BuildContext context) {
Gestión del Botón Atrás (PopScope)
Se utiliza el widget PopScope para interceptar el botón físico "atrás" del dispositivo Android. Esto permite controlar manualmente el comportamiento de navegación en lugar de cerrar la app inesperadamente.
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
// Si existe historial interno de pestañas, vuelve a la anterior
if (_tabHistory.isNotEmpty) {
setState(() {
currentIndex = _tabHistory.removeLast();
});
controller?.loadUrl(
urlRequest: URLRequest(
url: WebUri(_getUrlByIndex(currentIndex)),
),
);
return;
}
// Si no hay historial, se cierra la aplicación de forma segura
SystemNavigator.pop();
},
Estructura Base (Scaffold y SafeArea)
La aplicación utiliza un Scaffold como contenedor principal para organizar el cuerpo de la app y la barra de navegación. Además, se implementa SafeArea para garantizar que el contenido no se superponga con elementos del sistema (como el notch o la barra de estado).
child: Scaffold(
body: SafeArea(
top: true,
bottom: false,
child: Stack(
children: [
// El contenido se organiza en un Stack para superponer elementos
],
),
),
),
Renderizado de Contenido Dinámico
El contenido principal se gestiona dentro de un Stack, permitiendo alternar entre la visualización web y los estados de carga o error.
- WebView y Conectividad Si el dispositivo cuenta con acceso a internet, se despliega la InAppWebView cargando la URL correspondiente a la pestaña activa.
if (hasConnection)
InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri(_getUrlByIndex(currentIndex)),
),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true, // Ejecución de scripts del sitio
domStorageEnabled: true, // Almacenamiento local para persistencia
cacheEnabled: true, // Optimización de carga mediante caché
),
)
else
_noConnectionView(), // Vista alternativa en caso de desconexión
- Indicador de Carga (Loader) Cuando se detecta una transición de página o carga de recursos, se superpone un indicador visual sobre la WebView.
if (isSceneLoading && hasConnection)
Container(
color: Colors.white.withOpacity(0.8), // Bloqueo visual ligero
child: const Center(
child: CircularProgressIndicator(
color: Color(0xFF1F2B5B), // Color institucional
),
),
),```
#### Barra de Navegación Inferior
Finalmente, se define la bottomNavigationBar. Se utiliza un SizedBox para ajustar la altura dinámicamente según el padding del dispositivo (evitando solapamientos con gestos del sistema).
```bottomNavigationBar: SizedBox(
height: 64 + MediaQuery.of(context).viewPadding.bottom,
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: _handleTabTap,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
// ... otros ítems del menú
],
),
),
Esta arquitectura permite que la aplicación sea mantenible y centrada en el contenido web, ofreciendo una experiencia de usuario fluida que combina la potencia de la web con la navegación nativa de una app móvil.