Flutter: desarrollo móvil multiplataforma

Construye aplicaciones nativas multiplataforma profesionales con un solo código base: arquitectura interna con Impeller, un atlas visual exclusivo de 22 widgets esenciales, maquetación moderna con Material 3, navegación con GoRouter, gestión de estado (setState, Provider, Riverpod), APIs REST y un proyecto móvil integrador completo.

20 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Flutter 3.47 · Dart 3.13
20
Capítulos
22
Widgets en atlas visual
6
Partes del curso
0
Requisitos previos
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.
Capítulo 01

Arquitectura de Flutter, Impeller y entorno

Comprende las entrañas del SDK de Google: las tres capas del framework, la revolución del motor gráfico Impeller contra el jank, la preparación rigurosa del entorno multiplataforma y la creación de tu primer proyecto nativo.

  • Desglosar las tres capas de Flutter: Embedder, Engine (C++) y Framework (Dart).
  • Comprender el motor Impeller: por qué sustituye a Skia y erradica el molesto shader compilation jank.
  • Ejecutar el diagnóstico con Flutter Doctor: configurar toolchain en Linux, Windows y macOS.
  • Crear y ejecutar tu primera app: anatomía de archivos y la diferencia bajo el capó entre Hot Reload y Hot Restart.

1. ¿Por qué Flutter no usa WebView ni puentes OEM?

En el ecosistema móvil tradicional existen tres enfoques dominantes:

  • Nativo puro (Kotlin/Swift): compila directamente contra los widgets del sistema operativo (OEM). Máximo rendimiento, pero exige mantener dos bases de código separadas.
  • Híbrido WebView (Cordova/Capacitor): empaqueta HTML/CSS/JS dentro de un navegador embebido. Fácil reutilización web, pero penalización severa en tasa de refresco y respuesta gestual.
  • Puentes reactivos (React Native): ejecuta JavaScript en un hilo separado y se comunica mediante serialización asíncrona (Bridge/JSI) para manipular vistas nativas del SO. Si el puente se satura con eventos rápidos (scroll o animaciones complejas), la interfaz se congela.

Flutter rompe este paradigma pintando cada píxel directamente. No le pide al sistema operativo botones, barras ni interruptores nativos. Flutter incluye su propio motor de renderizado y una suite completa de widgets dibujados mediante operaciones gráficas de bajo nivel sobre un lienzo (Canvas). El resultado es un control milimétrico de la interfaz (pixel-perfect) y un rendimiento constante a 60 o 120 fotogramas por segundo (fps).

2. Las tres capas de la arquitectura interna

La arquitectura de Flutter se organiza en una jerarquía estricta de responsabilidades, desde los drivers del dispositivo hasta la API de widgets que consumes en Dart:

Capa Lenguaje / Tecnología Responsabilidades clave
Framework Dart Material & Cupertino: bibliotecas de diseño.
Widgets: catálogo declarativo inmutable.
Rendering: cálculo de cajas y coordenadas.
Animation, Painting, Gestures: eventos táctiles y física.
Engine C / C++ Impeller / Skia: rasterización gráfica por hardware.
Dart VM: ejecución AOT (producción) y JIT (desarrollo).
Text layout (LibTxt / HarfBuzz): renderizado de fuentes y glifos.
Platform Channels: interoperabilidad con APIs nativas del SO.
Embedder C++, Java/Kotlin, Obj-C/Swift Específico de cada plataforma anfitriona (Android, iOS, Windows, macOS, Linux, Web). Crea la ventana de superficie gráfica, gestiona el bucle de eventos del SO y entrega el acceso al teclado y sensores.

3. La revolución de Impeller: erradicando el Shader Jank

Durante años, Flutter utilizó la biblioteca gráfica Skia. Skia funcionaba de manera impecable en la mayoría de escenarios, pero sufría de un problema conocido como Shader Compilation Jank: cuando un usuario abría una pantalla con una animación, gradiente o sombra nueva por primera vez, el driver de la GPU tenía que compilar los sombreadores (shaders) sobre la marcha en tiempo de ejecución. Esta compilación tardaba entre 30 y 100 milisegundos, provocando una caída perceptible de fotogramas (un "tirón" o stutter).

Para erradicar definitivamente este defecto, el equipo de Flutter diseñó Impeller desde cero:

  • Precompilación AOT de shaders: todos los sombreadores y pipelines gráficos se compilan en tiempo de construcción (build time) utilizando Metal en iOS y Vulkan (con fallback a OpenGL) en Android.
  • Predicibilidad absoluta: ningún fotograma se demora compilando código de GPU mientras el usuario interactúa con la pantalla.
  • Uso concurrente de la GPU: aprovecha al máximo las colas de renderizado paralelas de los procesadores modernos.
Estado de Impeller en Flutter 3.x: Impeller viene activado de forma predeterminada para iOS y en la gran mayoría de dispositivos Android modernos. En caso de requerir validación o forzar su comportamiento en desarrollo Android, se puede usar el flag --enable-impeller o inspeccionarlo en tiempo de ejecución con Flutter DevTools.

4. Instalación limpia y diagnóstico con Flutter Doctor

El SDK de Flutter incluye el compilador de Dart, las herramientas de línea de comandos y los envoltorios nativos. Para instalarlo de forma limpia en tu estación de trabajo:

# 1. En Linux / macOS (descarga directa del canal estable)
# O clonando el repositorio oficial en una ruta sin permisos de root:
git clone https://github.com/flutter/flutter.git -b stable ~/flutter

# 2. Exportar la ruta en tu ~/.bashrc o ~/.zshrc
export PATH="$HOME/flutter/bin:$PATH"
source ~/.bashrc

# 3. Comprobar la versión instalada
flutter --version

Una vez configurado el PATH, el comando fundamental es flutter doctor. Esta utilidad audita el sistema operativo en busca de dependencias faltantes (Java JDK, Android SDK, Android Studio, VS Code y cadenas de compilación nativas):

# Diagnóstico completo con salida detallada
flutter doctor -v

# Aceptar todas las licencias del SDK de Android
flutter doctor --android-licenses
Salida esperada de flutter doctor (sistema listo)
[✓] Flutter (Channel stable, 3.47.4, on Linux 6.8.0, locale es_ES.UTF-8)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Chrome - develop for the web
[✓] Linux toolchain - develop for Linux desktop
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.93.0)
[✓] Connected device (2 available)
[✓] Network resources

• No issues found!

5. Creación del primer proyecto y anatomía de carpetas

Para inicializar una nueva aplicación utilizamos el comando flutter create. Recomendamos especificar siempre la organización inversa de dominio mediante el flag --org para fijar el Package Name (Android) y Bundle Identifier (iOS):

# Creación con identificador de organización
flutter create --org com.webcode mi_primera_app

# Ingresar al proyecto
cd mi_primera_app

# Ejecutar en el dispositivo o emulador conectado
flutter run

La estructura de archivos generada es limpia y modular:

  • lib/: Aquí vive todo tu código Dart. El punto de entrada indiscutible es lib/main.dart.
  • pubspec.yaml: El manifiesto del proyecto. Declara el nombre de la app, versión, dependencias de paquetes (de pub.dev), fuentes y rutas de imágenes (assets).
  • android/ e ios/: Proyectos anfitriones nativos (Gradle y Xcode). El 90% del tiempo no necesitas tocarlos, salvo para configurar permisos de cámara/GPS, llaves de firma o splash screens.
  • web/, linux/, windows/, macos/: Proyectos anfitriones para escritorio y navegador web.
  • test/: Suites de pruebas unitarias y de widgets.

6. El superpoder del desarrollo: Hot Reload vs Hot Restart

Uno de los factores que catapulta la productividad en Flutter es la velocidad de iteración. Es crítico dominar la diferencia técnica entre ambos mecanismos:

Acción Atajo CLI ¿Qué ocurre internamente? Preservación de Estado
Hot Reload r Inyecta el nuevo código compilado en la Dart VM y reconstruye el árbol de widgets inmediatamente (~200ms). Conserva el estado (contadores, campos de texto y formularios quedan intactos).
Hot Restart R Destruye el estado completo, reinicia la Dart VM y vuelve a ejecutar la función main() desde cero (~1s). Pierde el estado (devuelve la app a su condición inicial limpia).
Full Rebuild Detener y flutter run Recompila binarios nativos de Android/iOS (Gradle/Xcode). Necesario cuando agregas plugins C++/Java/Swift o editas pubspec.yaml. Reinstalación completa en el dispositivo.

Regla de oro de desarrollo

Si modificas la función main(), variables estáticas globales o el método initState() de un StatefulWidget, Hot Reload no surtirá efecto porque ese código solo corre al nacer el componente. En esos casos, ejecuta siempre Hot Restart (R).

Puntos clave del capítulo

  • Flutter renderiza sobre un lienzo propio con control pixel a pixel, eliminando la sobrecarga de puentes OEM y WebViews.
  • El motor gráfico Impeller compila sombreadores AOT durante el build, eliminando los tirones (jank) de la primera animación.
  • flutter doctor es la herramienta canónica para auditar las dependencias del sistema y licencias del SDK.
  • Todo el código multiplataforma se ubica en lib/, con lib/main.dart como punto de entrada de la aplicación.
  • Hot Reload (r) actualiza la UI preservando el estado en memoria; Hot Restart (R) reinicia la máquina virtual.
Capítulo 02

Atlas y Glosario Visual de 22 Widgets

Reconoce visualmente la anatomía espacial de los componentes esenciales antes de programarlos. Guía de referencia rápida con diagramas vectoriales, esquemas de diseño y snippets para el 100% de los 22 widgets fundamentales de Flutter.

  • Interiorizar el modelo mental de pantalla: cómo encajan los slots del Scaffold en un teléfono móvil.
  • Diferenciar contenedores y ejes: Row (horizontal), Column (vertical) y Stack (capas en profundidad).
  • Distinguir cajas de espaciado: cuándo usar Container, Padding o SizedBox sin desperdiciar recursos.
  • Consultar la ficha técnica completa de los 22 widgets: cada uno con su esquema wireframe SVG dedicado, rol y código mínimo.

1. El Mapa Maestro: Anatomía de una Pantalla Móvil

En Flutter una pantalla no se maqueta mediante reglas de flotación ni posicionamiento absoluto descontrolado. Se utiliza una estructura arquitectónica donde el widget Scaffold actúa como esqueleto anfitrión y proporciona "ranuras" (slots) predefinidas para cada zona funcional:

Esquema estructural de ranuras en Scaffold (Material 3)
SCAFFOLD AppBar (Título) Drawer body (Column, ListView, GridView, etc.) + FloatingActionButton Inicio Buscar Perfil BottomNavigationBar / NavigationBar

Cada área representa un parámetro nombrado directo en el constructor de Scaffold.

2. Catálogo Visual de los 22 Widgets Esenciales

A continuación desglosamos cada uno de los 22 componentes que conforman la columna vertebral de cualquier aplicación en Flutter, organizados por su rol arquitectónico con su esquema gráfico representativo:

Categoría I · Componentes de Estructura y Navegación

1. Scaffold
Estructura

Esqueleto básico visual de una pantalla en Material Design. Ofrece ranuras directas para barra superior, cuerpo central, cajón lateral y botones flotantes.

appBar body + bottomNavigationBar
Scaffold(
  appBar: AppBar(title: const Text('Inicio')),
  body: const Center(child: Text('Hola Flutter')),
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
);
2. AppBar
Navegación

Barra de herramientas superior. Aloja el icono de navegación (leading), el título de la vista y botones de acción (actions).

Título Pantalla
AppBar(
  leading: const BackButton(),
  title: const Text('Detalle de Pedido'),
  actions: [
    IconButton(icon: const Icon(Icons.share), onPressed: () {}),
  ],
);
3. BottomNavigationBar
Navegación

Barra inferior que permite alternar entre destinos primarios de la aplicación mediante un toque en pantalla.

Home Buscar Ajustes
BottomNavigationBar(
  currentIndex: 0,
  onTap: (index) {},
  items: const [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
    BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Perfil'),
  ],
);
4. Drawer
Navegación

Panel lateral oculto que se despliega horizontalmente desde el borde de la pantalla para presentar accesos secundarios o perfil del usuario.

Usuario Demo
Drawer(
  child: ListView(
    children: const [
      DrawerHeader(child: Text('Menú Principal')),
      ListTile(leading: Icon(Icons.settings), title: Text('Ajustes')),
    ],
  ),
);
5. TabBar / TabBarView
Pestañas

Sistema de navegación por pestañas en paralelo. TabBar muestra los botones de encabezado con indicador deslizante y TabBarView conmuta las vistas sincronizadas mediante gestos de swipe.

Pestaña 1 Pestaña 2 ← Deslizar vista →
DefaultTabController(
  length: 2,
  child: Scaffold(
    appBar: AppBar(
      bottom: const TabBar(
        tabs: [Tab(text: 'Pendientes'), Tab(text: 'Completados')],
      ),
    ),
    body: const TabBarView(
      children: [Text('Vista 1'), Text('Vista 2')],
    ),
  ),
);

Categoría II · Componentes de Diseño y Contenedores

6. Container
Contenedor

Caja multipropósito. Permite fijar dimensiones, márgenes (margin), rellenos (padding), colores de fondo, bordes redondeados y sombras complejas (BoxDecoration).

child margin → border → padding
Container(
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: const [BoxShadow(blurRadius: 4, color: Colors.black12)],
  ),
  child: const Text('Caja decorada'),
);
7. Padding
Espaciado

Widget de alta eficiencia optimizado para una única tarea: inyectar espacio interno vacío alrededor de su elemento hijo, sin el costo de un Container completo.

child padding
Padding(
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
  child: const Text('Texto con respiro'),
);
8. SizedBox
Dimensiones

Caja de dimensiones exactas e invariables. Se emplea para forzar anchos/altos precisos o como separador en blanco entre elementos de una fila o columna.

16 px
// Separador vertical limpio y constante
const SizedBox(height: 16);

// Caja con tamaño forzado
SizedBox(
  width: double.infinity,
  child: ElevatedButton(onPressed: () {}, child: const Text('Botón ancho')),
);
9. Card
Superficie

Panel superficial con bordes redondeados y sombra calculada por elevación (efecto z-axis en Material Design). Agrupa información relacionada de forma destacada.

Título Card Elevación y bordes suaves
Card(
  elevation: 2,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  child: const ListTile(
    leading: Icon(Icons.person),
    title: Text('Juan Pérez'),
    subtitle: Text('Paciente asignado'),
  ),
);

Categoría III · Componentes de Alineación y Distribución

10. Row
Flujo X

Distribuidor lineal horizontal. Organiza sus widgets hijos en fila de izquierda a derecha. Eje principal (MainAxis): horizontal; eje cruzado (CrossAxis): vertical.

Eje Principal →
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: const [
    Icon(Icons.star),
    Text('Puntuación: 4.8'),
    Icon(Icons.arrow_forward),
  ],
);
11. Column
Flujo Y

Distribuidor lineal vertical. Apila sus widgets hijos uno debajo del otro. Eje principal (MainAxis): vertical; eje cruzado (CrossAxis): horizontal.

↓ Eje Y
Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: const [
    Text('Título', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
    SizedBox(height: 8),
    Text('Subtítulo o descripción corta'),
  ],
);
12. Stack
Capas Z

Contenedor de superposición. Permite colocar widgets unos encima de otros en el eje Z (fondos con imágenes, etiquetas de descuento, badges de notificación flotantes).

3
Stack(
  children: [
    Image.asset('banner.jpg'),
    Positioned(
      bottom: 10,
      left: 10,
      child: const Text('Texto sobre imagen'),
    ),
  ],
);
13. Center
Alineación

Componente de alineación que centra de forma matemática a su único widget hijo, tanto en el eje horizontal como en el vertical, dentro del espacio que le concede su padre.

Center
const Center(
  child: CircularProgressIndicator(),
);

Categoría IV · Componentes de Texto y Visualización

14. Text
Tipografía

Componente fundamental para pintar cadenas de texto. Configura fuentes, pesos, interlineados, desbordamiento (overflow) y estilos con TextStyle.

Título Principal (Bold) Cuerpo de texto con TextStyle
const Text(
  'Total a pagar: S/ 120.00',
  style: TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.bold,
    color: Colors.blueAccent,
  ),
);
15. Icon
Glifos

Pinta glifos vectoriales escalables de la librería del sistema (Material Icons). Permite ajustar tamaño y color sin pérdida de nitidez.

16 px 24 px 32 px
const Icon(
  Icons.check_circle,
  color: Colors.green,
  size: 32,
);
16. Image
Gráficos

Carga mapas de bits desde internet (Image.network), archivos locales del paquete (Image.asset) o memoria binaria.

BoxFit.cover
Image.network(
  'https://webcode.net.pe/logo.png',
  width: 120,
  fit: BoxFit.contain,
);

Categoría V · Componentes de Interacción y Formularios

17. ElevatedButton
Botón primario

Botón estándar con relieve, color de fondo y sombra proyectada. Resalta la acción primordial de un formulario o vista.

ElevatedButton
ElevatedButton(
  onPressed: () => procesar(),
  child: const Text('Confirmar'),
);
18. TextButton
Botón plano

Botón sin borde ni fondo visible en reposo. Ideal para acciones secundarias, enlaces de cancelación o dentro de cuadros de diálogo.

TextButton (Plano)
TextButton(
  onPressed: () => Navigator.pop(context),
  child: const Text('Cancelar'),
);
19. IconButton
Botón icono

Botón compuesto únicamente por un glifo con efecto de ondas táctiles (ink ripple). Muy común en barras de herramientas.

IconButton(
  icon: const Icon(Icons.favorite_border),
  onPressed: () => alternarFavorito(),
);
20. TextField
Entrada de texto

Campo de texto interactivo. Gestiona la apertura del teclado táctil, decoraciones de borde, prefijos, sufijos y captura mediante TextEditingController.

usuario@clinica.pe Correo electrónico
TextField(
  controller: emailCtrl,
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(
    labelText: 'Correo electrónico',
    border: OutlineInputBorder(),
    prefixIcon: Icon(Icons.email),
  ),
);
21. FloatingActionButton
Botón flotante

Botón circular flotante (FAB) que se eleva sobre el contenido general para activar la acción más determinante y trascendental de la pantalla (crear, agregar, disparar).

Acción flotante
FloatingActionButton(
  onPressed: () => abrirNuevo(),
  backgroundColor: Colors.blueAccent,
  child: const Icon(Icons.add),
);

Categoría VI · Componentes de Listas y Desplazamiento

22. ListView
Lista deslizable

Lista lineal desplazable. Su constructor ListView.builder es la solución canónica para renderizar miles de registros de forma perezosa (lazy loading), reciclando memoria al salir de pantalla.

↑↓
ListView.builder(
  itemCount: pacientes.length,
  itemBuilder: (context, index) {
    return ListTile(
      leading: const Icon(Icons.person),
      title: Text(pacientes[index].nombre),
    );
  },
);
23. GridView
Cuadrícula 2D

Malla bidimensional deslizante en filas y columnas. Ideal para catálogos comerciales de productos, tableros de fotos, paneles de métricas o galerías.

GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 10,
    mainAxisSpacing: 10,
  ),
  itemCount: productos.length,
  itemBuilder: (context, i) => ProductoCard(productos[i]),
);

Cómo utilizar este Atlas a lo largo del curso

Guarda este capítulo en tus marcadores de cabecera. Cada vez que diseñes una nueva pantalla, regresa aquí para contrastar qué widget resuelve tu necesidad de espacio con el menor consumo de CPU. En los capítulos siguientes tomaremos cada uno de estos bloques para conectarlos a datos dinámicos, validaciones reactivas y lógica de negocio.

Puntos clave del capítulo

  • Scaffold proporciona la estructura base de pantalla con ranuras para appBar, body, floatingActionButton y bottomNavigationBar.
  • Row y Column organizan widgets linealmente en un solo eje, mientras que Stack permite superposición en capas sobre el eje Z.
  • Para espaciar, prefiere SizedBox o Padding antes de crear un Container innecesario.
  • ListView.builder y GridView.builder implementan reciclaje de memoria inteligente para conjuntos de datos grandes o infinitos.
  • Toda la interfaz en Flutter se construye componiendo estos widgets primitivos en un árbol jerárquico.
Capítulo 03

Anatomía de una App y ciclo de vida

Descifra el engranaje interno de una aplicación en Flutter: el punto de entrada con runApp(), el triple árbol de renderizado (Widget, Element, RenderObject), la diferencia arquitectónica entre StatelessWidget y StatefulWidget, y el ciclo de vida riguroso de la clase State.

  • Rastrear el arranque: qué hace WidgetsFlutterBinding.ensureInitialized() y runApp().
  • Dominar el triple árbol: cómo colaboran la capa declarativa (Widget), la estructural (Element) y la geométrica (RenderObject).
  • Elegir entre Stateless y Stateful: inmutabilidad con const frente a estado persistente.
  • Controlar el ciclo de vida de State: de initState() a dispose(), evitando memory leaks y errores con mounted.

1. El Punto de Entrada: main() y runApp()

Toda aplicación Flutter nace en la función clásica de Dart void main() ubicada en lib/main.dart. Dentro de ella se invoca a runApp():

import 'package:flutter/material.dart';

void main() {
  // Asegura que los canales binarios del sistema estén listos
  // (Obligatorio si inicializas plugins como Firebase, SharedPreferences o SQLite antes de runApp)
  WidgetsFlutterBinding.ensureInitialized();

  runApp(const MiAplicacion());
}

class MiAplicacion extends StatelessWidget {
  const MiAplicacion({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Clínica Móvil',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF02569B)),
        useMaterial3: true,
      ),
      home: const Scaffold(
        body: Center(child: Text('Bienvenido al sistema')),
      ),
    );
  }
}

Bajo el capó, runApp() ejecuta tres acciones críticas:

  1. Infla el widget raíz que le pasas como parámetro y lo ancla a la ventana nativa mediante el RenderView.
  2. Crea el Element raíz que coordinará todo el árbol de vistas.
  3. Programa el primer fotograma en el motor gráfico Impeller para pintar la pantalla de inmediato.

2. El Triple Árbol de Flutter: ¿Por qué es tan rápido?

Uno de los mayores secretos de rendimiento de Flutter reside en que no maneja un solo árbol de interfaz, sino tres árboles sincronizados en memoria:

Árbol Características Responsabilidad en la arquitectura
1. Widget Tree Inmutable, ligero, desechable Es el plano o receta declarativa que escribes en Dart. Crear widgets cuesta casi cero memoria; Flutter puede destruirlos y recrearlos 60 o 120 veces por segundo sin problema.
2. Element Tree Mutable, persistente, gestor de ciclo Es el "cerebro" estructural. Guarda la instancia viva en memoria, enlaza el Widget con el RenderObject, mantiene el estado (en StatefulWidgets) y decide cuándo un componente puede reutilizarse comparando su tipo y su Key.
3. RenderObject Tree Pesado, persistente, cálculo espacial Es el objeto que realiza el trabajo duro: calcula restricciones de tamaño (layout), detecta impactos táctiles (hitTest) y pinta los píxeles en el Canvas (paint) para que Impeller los rasterice. Solo se recalcula cuando cambian dimensiones o colores reales.

¿Qué ocurre cuando cambia un dato?

Cuando se reconstruye un widget, Flutter no destruye el RenderObject si el tipo y la llave coinciden. Simplemente actualiza sus propiedades (por ejemplo, cambia el color de fondo) y le pide a Impeller repintar esa zona. Por eso Flutter alcanza 120 fps sostenidos: el 95% del árbol pesado se recicla intacto.

3. StatelessWidget vs StatefulWidget

Todos los widgets de tu aplicación heredan directa o indirectamente de una de estas dos clases base:

StatelessWidget

Representa una interfaz estática que solo depende de los parámetros que recibe en su constructor. Todos sus campos deben ser final y sus constructores deben declararse con const siempre que sea posible.

class TarjetaUsuario extends StatelessWidget {
  final String nombre;
  final String rol;

  const TarjetaUsuario({
    super.key,
    required this.nombre,
    required this.rol,
  });

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: const Icon(Icons.person),
      title: Text(nombre),
      subtitle: Text(rol),
    );
  }
}

StatefulWidget

Representa una interfaz dinámica cuyos datos cambian durante el ciclo de vida en respuesta a eventos del usuario o respuestas de red. Se divide obligatoriamente en dos clases separadas:

// 1. El Widget (inmutable y ligero)
class ContadorWidget extends StatefulWidget {
  const ContadorWidget({super.key});

  @override
  State<ContadorWidget> createState() => _ContadorWidgetState();
}

// 2. El State (mutable y persistente)
class _ContadorWidgetState extends State<ContadorWidget> {
  int _contador = 0;

  void _incrementar() {
    setState(() => _contador++);
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: _incrementar,
      child: Text('Clics: $_contador'),
    );
  }
}

4. El Ciclo de Vida Completo de la Clase State

El objeto State cuenta con una secuencia determinista de etapas desde que nace hasta que es destruido de la memoria. Dominar este ciclo es imprescindible para conectar controladores, escuchar streams y prevenir fugas de memoria:

Diagrama de flujo · Ciclo de vida de StatefulWidget (State)
FASE 1 · INICIALIZACIÓN (MOUNT) FASE 2 · BUCLE REACTIVO (UPDATING) FASE 3 · DESTRUCCIÓN (UNMOUNT) createState() mounted = true initState() 1 sola vez · Controllers, Streams, Listeners didChangeDependencies() InheritedWidget · Theme · MediaQuery build(BuildContext context) Renderizado puro · Se ejecuta en cada actualización didUpdateWidget() Padre pasa nuevos datos setState(() { ... }) Marca elemento dirty Programa re-render Widget removido deactivate() dispose() Limpieza · Cancelar Timers, Streams y Controllers mounted = false (Destruido)

Los métodos verdes y azules corresponden al nacimiento, el bucle central al re-renderizado reactivo y los rojos a la liberación obligatoria de recursos.

Método Frecuencia Propósito técnico y qué código escribir aquí
createState() 1 vez Invocado por Flutter inmediatamente al insertar el StatefulWidget en el árbol para instanciar su clase de estado.
initState() 1 vez Inicialización única. Aquí configuras controladores (TextEditingController, ScrollController), te suscribes a Streams o disparas peticiones HTTP iniciales. Siempre debes llamar primero a super.initState().
didChangeDependencies() 1 o más veces Se ejecuta justo después de initState() y cada vez que cambia un InheritedWidget del cual depende este widget (por ejemplo, si el usuario cambia el tema claro/oscuro o la orientación de pantalla con MediaQuery).
build() Muchas veces Renderizado declarativo. Retorna el árbol de widgets. Debe ser un método puro, rápido y libre de efectos secundarios (¡nunca dispares peticiones HTTP ni inicies timers dentro de build()!).
didUpdateWidget() Ocasional Se dispara si el widget padre se reconstruye y le pasa nuevos parámetros al widget hijo manteniendo el mismo State. Permite comparar oldWidget contra widget para reaccionar a cambios.
setState() A demanda Notifica al framework que las variables internas han mutado. Marca el elemento como dirty y programa una nueva llamada a build() en el siguiente refresco de pantalla.
deactivate() Rara vez Se invoca cuando el elemento es retirado temporalmente del árbol (por ejemplo, al moverlo de posición mediante una GlobalKey).
dispose() 1 vez (final) Destrucción y limpieza obligatoria. Aquí liberas timers, cancelas suscripciones a Streams y llamas a controller.dispose() para erradicar memory leaks. Siempre finaliza llamando a super.dispose().

5. La Trampa Clásica de Asincronía: La propiedad mounted

Cuando ejecutas operaciones asíncronas (como una llamada HTTP o lectura de base de datos) dentro de un State, el usuario puede retroceder de pantalla antes de que la petición termine. Si el widget ya no existe e intentas llamar a setState(), Flutter arrojará una excepción severa: «setState() called after dispose()».

La solución profesional es verificar la propiedad booleana mounted antes de mutar la interfaz:

Future<void> _cargarDatosServidor() async {
  final resultado = await servicioApi.obtenerPerfil();

  // Guarda de seguridad: verifica si el widget sigue vivo en pantalla
  if (!mounted) return;

  setState(() {
    _perfil = resultado;
    _cargando = false;
  });
}

Puntos clave del capítulo

  • main() arranca con runApp(), el cual ancla el árbol de widgets a la superficie gráfica nativa.
  • Flutter mantiene tres árboles: Widgets (configuración inmutable), Elements (gestión estructural de ciclo) y RenderObjects (cálculo geométrico y pintura).
  • Usa StatelessWidget para interfaces estáticas y StatefulWidget cuando el componente deba almacenar y mutar datos interactivos.
  • En el ciclo de vida de State, inicializa en initState() y libera obligatoriamente recursos en dispose().
  • Comprueba siempre if (!mounted) return; tras operaciones con await antes de invocar setState().
Capítulo 04

Estructura de pantalla con Scaffold

Domina el contenedor estructural por excelencia de Material 3: configuración exhaustiva de AppBar, cajones de navegación laterales (Drawer y EndDrawer), botones flotantes (FAB), notificaciones reactivas con SnackBar y ventanas emergentes inferiores.

  • Configurar la AppBar profesionalmente: ranuras leading, title, actions y elevación tonal en Material 3.
  • Implementar menús deslizantes: estructura de Drawer y UserAccountsDrawerHeader con cierre determinista.
  • Dominar el FloatingActionButton: variantes M3 (small, regular, large, extended) y anclaje espacial con floatingActionButtonLocation.
  • Disparar notificaciones y BottomSheets: uso del estándar ScaffoldMessenger y cuadros de diálogo modales inferiores.

1. La Barra de Herramientas: AppBar a Fondo

En Material 3, la AppBar no es simplemente una barra de color plano con un título de texto. Es una superficie interactiva que coordina navegación, acciones globales y respuesta a eventos de desplazamiento:

AppBar(
  // 1. Icono de navegación izquierdo (Drawer o flecha de retorno)
  leading: IconButton(
    icon: const Icon(Icons.menu),
    tooltip: 'Abrir menú',
    onPressed: () => Scaffold.of(context).openDrawer(),
  ),

  // 2. Título principal centrado o alineado al inicio
  title: const Text('Gestión de Citas'),
  centerTitle: false,

  // 3. Botones de acción en la esquina superior derecha
  actions: [
    IconButton(
      icon: const Icon(Icons.search),
      tooltip: 'Buscar paciente',
      onPressed: () {},
    ),
    IconButton(
      icon: const Icon(Icons.filter_list),
      tooltip: 'Filtrar',
      onPressed: () {},
    ),
  ],

  // 4. Parámetros visuales en Material 3
  elevation: 0,
  scrolledUnderElevation: 3.0, // Elevación tonal automática al hacer scroll
  backgroundColor: Theme.of(context).colorScheme.surface,
);

Elevación tonal en Material 3

A diferencia de Material 2, que utilizaba sombras oscuras pesadas para simular altura física, Material 3 utiliza elevación tonal (surface tint): a mayor elevación, el color de la superficie se tiñe sutilmente con el color primario de tu paleta (ColorScheme.primary), conservando un contraste óptico perfecto tanto en modo claro como en modo oscuro.

2. Menús Desplegables: Drawer y EndDrawer

El Scaffold permite declarar dos tipos de cajones laterales:

  • drawer: se despliega desde el borde izquierdo (en idiomas de lectura izquierda a derecha). Si no defines un leading manual en la AppBar, Flutter dibuja automáticamente el icono de hamburguesa que lo activa.
  • endDrawer: se despliega desde el borde derecho. Excelente para filtros secundarios, paneles de auditoría o carritos de compra.
Drawer(
  child: ListView(
    padding: EdgeInsets.zero,
    children: [
      // Encabezado con información del usuario autenticado
      UserAccountsDrawerHeader(
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.primary,
        ),
        currentAccountPicture: const CircleAvatar(
          backgroundColor: Colors.white,
          child: Icon(Icons.person, size: 36, color: Color(0xFF02569B)),
        ),
        accountName: const Text('Dr. Carlos Mendoza'),
        accountEmail: const Text('carlos.mendoza@clinica.pe'),
      ),

      // Opciones de navegación
      ListTile(
        leading: const Icon(Icons.calendar_today),
        title: const Text('Citas de Hoy'),
        onTap: () {
          // 1. Cerrar el drawer primero
          Navigator.pop(context);
          // 2. Navegar a la pantalla deseada
        },
      ),
      ListTile(
        leading: const Icon(Icons.people_outline),
        title: const Text('Directorio de Pacientes'),
        onTap: () => Navigator.pop(context),
      ),
      const Divider(),
      ListTile(
        leading: const Icon(Icons.logout, color: Colors.redAccent),
        title: const Text('Cerrar Sesión', style: TextStyle(color: Colors.redAccent)),
        onTap: () => Navigator.pop(context),
      ),
    ],
  ),
);

3. Botones Flotantes: Variantes y Posicionamiento del FAB

El FloatingActionButton representa la acción primordial de la vista. En Material 3 se introducen variantes de tamaño y un widget extendido para incluir texto descriptivo:

Constructor / Variante Dimensiones M3 Caso de uso recomendado
FloatingActionButton.small() 40 × 40 px Acciones secundarias en pantallas compactas o vistas con múltiples botones.
FloatingActionButton() 56 × 56 px El estándar tradicional para la acción principal (+, editar, guardar).
FloatingActionButton.large() 96 × 96 px Interfaces en tablets, pantallas de quiosco o acciones de alta prioridad.
FloatingActionButton.extended() Altura 56 px, ancho auto Combina un icono con una etiqueta de texto legible (ej: «Nueva Cita»).
Scaffold(
  // Ubicaciones: endFloat (default), centerFloat, centerDocked, endDocked
  floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
  floatingActionButton: FloatingActionButton.extended(
    onPressed: () => registrarNuevaCita(),
    icon: const Icon(Icons.add),
    label: const Text('Nueva Cita'),
  ),
  body: const Center(child: Text('Contenido principal')),
);

4. Notificaciones Reactivas con SnackBar y ScaffoldMessenger

Para mostrar mensajes de confirmación o avisos breves en la parte inferior de la pantalla, Flutter provee SnackBar. En versiones modernas de Flutter es obligatorio orquestar su apertura mediante ScaffoldMessenger:

void mostrarMensajeExito(BuildContext context, String mensaje) {
  // 1. Ocultar SnackBar previo si estuviese visible
  ScaffoldMessenger.of(context).hideCurrentSnackBar();

  // 2. Disparar el nuevo aviso configurado
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Row(
        children: [
          const Icon(Icons.check_circle_outline, color: Colors.white),
          const SizedBox(width: 10),
          Expanded(child: Text(mensaje)),
        ],
      ),
      duration: const Duration(seconds: 3),
      behavior: SnackBarBehavior.floating, // Flota sobre la pantalla sin pegarse al borde
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
      action: SnackBarAction(
        label: 'DESHACER',
        textColor: Colors.amberAccent,
        onPressed: () {
          // Lógica de reversión inmediata
        },
      ),
    ),
  );
}

5. Paneles Modales Inferiores: showModalBottomSheet

Cuando requieres que el usuario seleccione una opción o complete un flujo secundario sin abandonar la pantalla actual, el patrón recomendado en móviles es el Bottom Sheet Modal:

void mostrarOpciones(BuildContext context) {
  showModalBottomSheet(
    context: context,
    showDragHandle: true, // Tirador visual nativo de arrastre en Material 3
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (BuildContext ctx) {
      return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: Column(
          mainAxisSize: MainAxisSize.min, // Ocupa solo la altura requerida
          children: [
            ListTile(
              leading: const Icon(Icons.photo_camera),
              title: const Text('Tomar fotografía'),
              onTap: () => Navigator.pop(ctx, 'camara'),
            ),
            ListTile(
              leading: const Icon(Icons.photo_library),
              title: const Text('Elegir de la galería'),
              onTap: () => Navigator.pop(ctx, 'galeria'),
            ),
          ],
        ),
      );
    },
  );
}

Puntos clave del capítulo

  • Scaffold organiza de forma coherente las ranuras de appBar, drawer, body y floatingActionButton.
  • En Material 3, AppBar implementa elevación tonal automática con scrolledUnderElevation.
  • Dentro de un Drawer, invoca siempre Navigator.pop(context) al pulsar un elemento para cerrar el menú antes de navegar.
  • Utiliza ScaffoldMessenger.of(context).showSnackBar() para emitir mensajes transitorios flotantes con soporte para acciones de usuario.
  • showModalBottomSheet con showDragHandle: true y mainAxisSize: MainAxisSize.min ofrece el estándar moderno para flujos secundarios.
Capítulo 05

Distribución y flujo: Row, Column y Stack

Aprende a maquetar interfaces complejas y responsivas: los sistemas de ejes en Row y Column, la distribución proporcional con Expanded y Flexible, la solución definitiva al error de desbordamiento por píxeles y la superposición de capas en el eje Z con Stack y Positioned.

  • Dominar los ejes primario y cruzado: alinear elementos con precisión mediante MainAxisAlignment y CrossAxisAlignment.
  • Controlar la flexibilidad del espacio: diferenciar Expanded, Flexible y el atajo elástico Spacer.
  • Erradicar el desbordamiento de pantalla: resolver el clásico error «A RenderFlex overflowed by X pixels» con Wrap y SingleChildScrollView.
  • Superponer capas visuales: componer diseños complejos en el eje Z mediante Stack y Positioned.

1. La Mecánica de Ejes en Row y Column

Tanto Row como Column derivan del widget interno Flex. La única diferencia entre ambos es la orientación del eje en el que distribuyen a sus hijos:

Widget Eje Principal (MainAxis) Eje Cruzado (CrossAxis)
Row Horizontal (X) · De izquierda a derecha Vertical (Y) · De arriba a abajo
Column Vertical (Y) · De arriba a abajo Horizontal (X) · De izquierda a derecha

Las opciones de alineación determinan el espaciado entre componentes:

  • MainAxisAlignment.start / end / center: agrupa a los hijos al principio, final o centro del eje.
  • MainAxisAlignment.spaceBetween: distribuye el espacio libre sobrante exclusivamente entre los hijos (los extremos quedan pegados al borde).
  • MainAxisAlignment.spaceAround: distribuye espacio equitativo alrededor de cada hijo (el espacio entre elementos es el doble que en los extremos).
  • MainAxisAlignment.spaceEvenly: todo el espacio (bordes y entre elementos) es matemáticamente idéntico.
  • CrossAxisAlignment.stretch: obliga a todos los hijos a expandirse hasta cubrir todo el ancho/alto del eje cruzado disponible.
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  // MainAxisSize.min hace que el Row o Column solo ocupe lo necesario
  mainAxisSize: MainAxisSize.max,
  children: const [
    Icon(Icons.access_time, color: Colors.blueGrey),
    Text('09:30 AM', style: TextStyle(fontWeight: FontWeight.bold)),
    Chip(label: Text('Confirmada')),
  ],
);

2. Espacio Flexible: Expanded vs Flexible vs Spacer

Cuando colocas elementos dentro de una fila o columna, los widgets rígidos (como textos largos o imágenes) pueden entrar en conflicto con el espacio restante. Para gestionar el espacio disponible proporcionalmente, disponemos de tres herramientas:

Expanded

Obliga al widget hijo a expandirse hasta llenar obligatoriamente todo el espacio libre restante en el eje principal (equivalente a fit: FlexFit.tight).

Row(
  children: const [
    Icon(Icons.mail),
    SizedBox(width: 8),
    Expanded(
      child: Text('Texto largo que jamás desborda la pantalla'),
    ),
  ],
);

Flexible

Le permite al widget hijo crecer hasta el espacio disponible si lo necesita, pero sin forzarlo a estirarse si su contenido intrínseco es menor (fit: FlexFit.loose).

Row(
  children: const [
    Flexible(
      child: Text('Texto corto que no fuerza expansión'),
    ),
    Icon(Icons.check),
  ],
);

Spacer

Un atajo declarativo elegante que inserta un espacio elástico vacío. Es exactamente equivalente a escribir Expanded(child: SizedBox()).

Row(
  children: const [
    Text('Total:'),
    Spacer(), // Empuja el precio al extremo
    Text('S/ 250.00'),
  ],
);

Mediante la propiedad flex puedes establecer relaciones de proporción (por ejemplo, dividir una fila en 70% / 30% asignando flex: 7 y flex: 3):

Row(
  children: [
    Expanded(flex: 3, child: Container(color: Colors.blueAccent, height: 40)),
    Expanded(flex: 1, child: Container(color: Colors.amberAccent, height: 40)),
  ],
);

3. Cómo Erradicar el Error: «A RenderFlex overflowed»

Todo desarrollador de Flutter se encuentra tarde o temprano con la temida franja rayada en amarillo y negro. Este error ocurre cuando los hijos de un Row o Column exigen más píxeles físicos de los que la pantalla puede ofrecer:

Escenario del Desbordamiento Causa habitual Solución canónica en Flutter
Texto largo en un Row El widget Text intenta crecer infinitamente en línea recta horizontal. Envolver el Text dentro de un Expanded o Flexible para forzar saltos de línea.
Formulario o Column con teclado Al abrirse el teclado virtual, la altura útil de pantalla se reduce a la mitad. Envolver la columna raíz dentro de un SingleChildScrollView con padding adecuado.
Etiquetas, tags o chips dinámicos Una lista horizontal de chips supera el ancho del dispositivo. Reemplazar el Row por un Wrap para que salten de fila automáticamente.
// Ejemplo: Tags dinámicos que se adaptan a múltiples líneas sin desbordar
Wrap(
  spacing: 8.0, // Separación horizontal entre chips
  runSpacing: 4.0, // Separación vertical entre líneas consecutivas
  children: const [
    Chip(label: Text('Medicina General')),
    Chip(label: Text('Cardiología')),
    Chip(label: Text('Pediatría')),
    Chip(label: Text('Dermatología')),
    Chip(label: Text('Traumatología')),
  ],
);

4. Superposición en el Eje Z: Stack y Positioned

Mientras que Row y Column distribuyen en una sola dimensión (1D), Stack permite superponer widgets unos encima de otros a lo largo del eje Z. El orden de declaración en la lista children define la profundidad: el primer elemento queda al fondo y el último se dibuja en la cima.

// Tarjeta de perfil médico con avatar e indicador de estado "En línea"
SizedBox(
  width: 90,
  height: 90,
  child: Stack(
    clipBehavior: Clip.none, // Permite que elementos sobresalgan del marco
    children: [
      // 1. Imagen base circular
      const CircleAvatar(
        radius: 42,
        backgroundImage: NetworkImage('https://webcode.net.pe/medico1.jpg'),
      ),

      // 2. Insignia flotante anclada a la esquina inferior derecha
      Positioned(
        bottom: 2,
        right: 2,
        child: Container(
          width: 18,
          height: 18,
          decoration: BoxDecoration(
            color: Colors.greenAccent[700],
            shape: BoxShape.circle,
            border: Border.all(color: Colors.white, width: 2.5),
          ),
        ),
      ),
    ],
  ),
);

5. ¿Qué Ocurre al Girar el Celular? Rotación y Adaptabilidad

Al girar un dispositivo físico de vertical (Portrait) a horizontal (Landscape), muchos desarrolladores temen perder los datos de su pantalla. En los sistemas nativos tradicionales (como Android con Java o Kotlin), la rotación destruye y vuelve a crear la Activity por defecto, borrando variables de memoria si no se gestionan explícitamente.

La Gran Ventaja de Flutter en la Rotación

¡Flutter NO destruye la aplicación ni borra tu State! El árbol de elementos y los objetos de estado en memoria RAM permanecen 100% intactos: los textos ingresados en los controladores, las listas y los contadores no se pierden. Lo que ocurre es que el motor detecta la inversión geométrica del tamaño de la ventana en MediaQueryData y dispara automáticamente una nueva pasada del método build(context) para adaptar el renderizado al nuevo Viewport.

Clínica · 1 Columna Portrait (Vertical) Alto: 844 px · Ancho: 390 px Giro a 90° Mecánica en Flutter ✓ State en RAM No se destruye ni borra ✓ MediaQuery Invierte dimensiones W / H ✓ Rebuild reactivo OrientationBuilder redibuja Landscape · Maestro - Detalle (2 Columnas) Cita Médica Seleccionada Dr. Morales · 09:00 AM · Consultorio 3 Confirmar Turno Landscape (Horizontal) Ancho: 844 px · Alto: 390 px (Sin RenderFlex Overflow)
Figura 5.1: Comportamiento ante la rotación de pantalla: el estado en RAM se conserva intacto, mientras OrientationBuilder redistribuye el layout vertical en 2 columnas maestras para aprovechar el ancho y eliminar el desbordamiento.

6. Implementación Adaptativa con OrientationBuilder

Para adaptar la interfaz automáticamente a la orientación física sin duplicar lógica de negocio, Flutter provee el widget OrientationBuilder:

import 'package:flutter/material.dart';

class DirectorioClinicoAdaptativo extends StatelessWidget {
  const DirectorioClinicoAdaptativo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Agenda Médica')),
      body: OrientationBuilder(
        builder: (BuildContext context, Orientation orientation) {
          // Si el usuario sostiene el teléfono en vertical: 1 sola columna
          if (orientation == Orientation.portrait) {
            return const ListaMedicosVertical();
          }

          // Si el usuario gira el teléfono en horizontal: 2 columnas maestro-detalle
          return const Row(
            children: [
              Expanded(flex: 2, child: ListaMedicosVertical()),
              VerticalDivider(width: 1),
              Expanded(flex: 3, child: FichaDetalleConsulta()),
            ],
          );
        },
      ),
    );
  }
}
¿Cómo Bloquear la Rotación de Pantalla?

Si tu aplicación móvil solo está diseñada para operar de forma vertical (por ejemplo, en terminales de pago POS o flujos muy estrictos), puedes bloquear la orientación globalmente antes de invocar runApp() mediante SystemChrome:

import 'package:flutter/services.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Forzar que la app solo se ejecute en vertical
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);
  runApp(const MiApp());
}

Puntos clave del capítulo

  • Row maneja el eje X como primario y el eje Y como cruzado; Column invierte este comportamiento.
  • Expanded consume todo el espacio libre restante forzosamente; Flexible permite al hijo respetar su tamaño intrínseco.
  • Usa Spacer() para empujar widgets a los extremos opuestos de una fila o columna.
  • Para evitar el error de desbordamiento (overflow), recurre a Expanded en textos, SingleChildScrollView en formularios y Wrap en chips.
  • Stack compone interfaces en capas sobre el eje Z y Positioned ubica elementos por coordenadas milimétricas.
  • Al girar el celular, Flutter NO destruye el objeto State; el MediaQueryData se recalcula y OrientationBuilder permite reconfigurar el layout en 2 columnas sin desbordamiento.
Capítulo 06

Cajas, decoración y dimensiones

Domina el estilizado visual y la geometría de interfaz: personalización profunda con BoxDecoration (bordes, gradientes, sombras y formas), la regla de oro para evitar conflictos de color, optimización con SizedBox y el uso de Card en Material 3.

  • Dominar BoxDecoration: aplicar colores, gradientes lineales, bordes perimetrales y sombras difusas (BoxShadow).
  • Evitar el assert de colisión de color: entender por qué no se debe mezclar color con decoration en un Container.
  • Optimizar el rendimiento del árbol: preferir SizedBox o Padding antes de crear contenedores sobrecargados.
  • Gestionar restricciones geométricas: utilizar ConstrainedBox y recortar desbordes con clipBehavior.

1. Anatomía y Decoración Profunda con Container

El widget Container es el equivalente más cercano a un <div> estilizado de la web. Combina posicionamiento, márgenes externos, rellenos internos, dimensiones fijas y decoración gráfica:

Container(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  padding: const EdgeInsets.all(20),
  decoration: BoxDecoration(
    // 1. Gradiente de fondo con dos paradas de color
    gradient: const LinearGradient(
      colors: [Color(0xFF02569B), Color(0xFF0175C2)],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),

    // 2. Esquinas redondeadas
    borderRadius: BorderRadius.circular(16),

    // 3. Borde perimetral sutil
    border: Border.all(color: Colors.white.withOpacity(0.2), width: 1.5),

    // 4. Sombras proyectadas en elevación
    boxShadow: [
      BoxShadow(
        color: const Color(0xFF02569B).withOpacity(0.35),
        offset: const Offset(0, 6),
        blurRadius: 12,
        spreadRadius: 1,
      ),
    ],
  ),
  child: const Text(
    'Tarjeta Promocional Clínica 2026',
    style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
  ),
);

La regla de oro: Colisión de color en Container

En Flutter, jamás debes declarar la propiedad color directamente en Container si ya estás utilizando decoration: BoxDecoration(...). El framework arrojará un error fatal en tiempo de ejecución: «Cannot provide both a color and a decoration». Cuando uses decoration, el color de fondo debe ir siempre dentro del BoxDecoration.

2. Comparativa de Rendimiento: ¿Container, Padding o SizedBox?

Un error muy extendido entre desarrolladores principiantes es recurrir a Container para cualquier separación visual. Bajo el capó, Container es un widget compuesto sumamente pesado que puede instanciar hasta seis widgets internos (DecoratedBox, Padding, LimitedBox, ConstrainedBox, Align y Transform).

Widget Costo en Memoria y Renderizado Cuándo utilizarlo exclusivamente
const SizedBox Mínimo / Casi cero Para generar espacios en blanco verticales/horizontales o forzar un ancho o alto exacto a un botón.
const Padding Muy bajo Cuando solo necesitas espacio interno alrededor de un hijo, sin fondos, bordes ni sombras.
Container Alto (compuesto) Úsalo únicamente si vas a combinar al menos dos características (ej. fondo con borde y margen).

3. Restricciones Geométricas con ConstrainedBox

En ocasiones deseas que una caja no tenga un tamaño rígido, sino que se mueva dentro de un rango aceptable (por ejemplo, que tenga un ancho mínimo de 120 px pero no supere los 300 px en pantallas grandes):

ConstrainedBox(
  constraints: const BoxConstraints(
    minWidth: 140,
    maxWidth: 280,
    minHeight: 48,
  ),
  child: ElevatedButton(
    onPressed: () {},
    child: const Text('Botón con tamaño inteligente'),
  ),
);

4. Tarjetas Material 3 con Card y ClipBehavior

El widget Card representa una superficie física elevada según las directrices de Material Design. Un problema frecuente ocurre al colocar imágenes dentro de una tarjeta: la imagen, por defecto, se dibuja en forma rectangular pura y tapa las esquinas redondeadas de la tarjeta. Para resolverlo se utiliza clipBehavior: Clip.antiAlias:

Card(
  elevation: 2,
  // clipBehavior recorta a los hijos para respetar el borde circular
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Image.network(
        'https://webcode.net.pe/sala_cirugia.jpg',
        height: 140,
        width: double.infinity,
        fit: BoxFit.cover,
      ),
      const Padding(
        padding: EdgeInsets.all(12),
        child: Text(
          'Quirófano Central 01',
          style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
        ),
      ),
    ],
  ),
);

Puntos clave del capítulo

  • BoxDecoration agrupa el color, bordes, gradientes y sombras de un Container.
  • Nunca mezcles el argumento color en la raíz de un Container si ya declaras decoration.
  • Utiliza const SizedBox() y const Padding() para ahorrar memoria en lugar de instanciar contenedores vacíos.
  • ConstrainedBox establece límites elásticos con minWidth, maxWidth, minHeight y maxHeight.
  • Aplica clipBehavior: Clip.antiAlias en un Card para que las imágenes respeten el redondeo de esquinas.
Capítulo 07

Listas y cuadrículas eficientes con scroll

Aprende a renderizar colecciones masivas de datos sin penalizar la memoria del dispositivo: reciclaje de memoria en el Viewport con ListView.builder, divisores automáticos con ListView.separated, mallas 2D con GridView y la maquetación limpia de filas mediante ListTile.

  • Dominar el reciclaje de Viewport: por qué ListView.builder es la solución obligatoria para listas de más de 20 elementos.
  • Insertar separadores limpios: implementar ListView.separated con divisores estandarizados.
  • Optimizar la tasa de refresco a 120 fps: acelerar el scroll mediante la propiedad itemExtent y evitar la trampa de shrinkWrap.
  • Construir cuadrículas responsivas: configurar GridView.builder con delegados de conteo fijo o extensión máxima.
  • Estructurar filas con ListTile: anatomía de ranuras con leading, title, subtitle y trailing.

1. La Ilusión del Scroll: ¿Cómo funciona el Viewport?

En una aplicación móvil con 5,000 registros de pacientes o productos, instanciar los 5,000 widgets de golpe en memoria provocaría un colapso por saturación de RAM (Out of Memory) y congelaría la pantalla durante segundos.

Flutter resuelve esto mediante el concepto de Viewport (Ventana de Visualización):

  • ListView(children: [...]): instancia todos los widgets de la lista inmediatamente al arrancar. Es aceptable únicamente para listas estáticas muy cortas (pantallas de configuración o menos de 15 items).
  • ListView.builder(): utiliza evaluación perezosa (lazy loading). Solo construye e instancia los widgets que son visibles en la pantalla del usuario en ese instante exacto. Conforme el usuario desliza hacia abajo, los widgets que salen por arriba son destruidos o reciclados, manteniendo un consumo de memoria constante y mínimo.
class Medico {
  final int id;
  final String nombre;
  final String especialidad;
  final bool activo;

  const Medico({required this.id, required this.nombre, required this.especialidad, required this.activo});
}

// Renderizado perezoso de 10,000 médicos con consumo constante de RAM
ListView.builder(
  itemCount: listaMedicos.length,
  itemBuilder: (BuildContext context, int index) {
    final medico = listaMedicos[index];
    return ListTile(
      leading: CircleAvatar(
        child: Text('${medico.id}'),
      ),
      title: Text(medico.nombre, style: const TextStyle(fontWeight: FontWeight.bold)),
      subtitle: Text(medico.especialidad),
      trailing: Icon(
        medico.activo ? Icons.check_circle : Icons.cancel,
        color: medico.activo ? Colors.green : Colors.grey,
      ),
      onTap: () {
        // Navegar al perfil del médico
      },
    );
  },
);

2. Separadores Automáticos con ListView.separated

Cuando requieres insertar una línea divisoria (Divider) o un espacio constante entre cada elemento sin tener que evaluar manualmente si te encuentras en el último registro:

ListView.separated(
  itemCount: pacientes.length,
  separatorBuilder: (context, index) => const Divider(
    height: 1,
    indent: 16,
    endIndent: 16,
  ),
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(pacientes[index].nombreCompleto),
      subtitle: Text('DNI: ${pacientes[index].documento}'),
    );
  },
);

3. Optimización Crítica: itemExtent y la Trampa de shrinkWrap

Para garantizar una tasa fluida de 60 o 120 fotogramas por segundo al desplazarse rápidamente por una lista, existen dos parámetros de rendimiento indispensables:

Propiedad Impacto en Rendimiento Explicación técnica
itemExtent: 72.0 Aceleración masiva Si todos los elementos de la lista miden la misma altura fija (por ejemplo, 72 px), infórmaselo a Flutter mediante itemExtent. Esto evita que el motor tenga que medir cada widget en tiempo de ejecución, permitiendo saltos instantáneos de scroll.
shrinkWrap: true Peligro en listas grandes Evita usar shrinkWrap: true en listas con más de 30 elementos. Esta propiedad obliga al ListView a calcular la altura total acumulada de todos sus hijos a la vez, anulando el reciclaje perezoso del Viewport y congelando la UI.

4. Cuadrículas Malla con GridView.builder

Para presentar catálogos comerciales, galerías fotográficas o paneles de métricas en dos dimensiones (filas y columnas), utilizamos GridView.builder:

GridView.builder(
  padding: const EdgeInsets.all(12),
  itemCount: especialidades.length,
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,        // 2 columnas exactas
    crossAxisSpacing: 10,     // Separación horizontal entre columnas
    mainAxisSpacing: 10,      // Separación vertical entre filas
    childAspectRatio: 1.2,    // Proporción ancho / alto de cada celda
  ),
  itemBuilder: (context, index) {
    final esp = especialidades[index];
    return Card(
      elevation: 2,
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(Icons.local_hospital, size: 32, color: Color(0xFF02569B)),
            const SizedBox(height: 8),
            Text(esp.nombre, style: const TextStyle(fontWeight: FontWeight.bold)),
          ],
        ),
      ),
    );
  },
);

Cuadrículas responsivas automáticas

Si deseas que tu cuadrícula se adapte dinámicamente entre teléfonos (2 columnas) y tablets (4 o 5 columnas) sin condicionales manuales, sustituye SliverGridDelegateWithFixedCrossAxisCount por SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 180): Flutter calculará cuántas columnas de máximo 180 px caben en el ancho de la pantalla actual.

Puntos clave del capítulo

  • ListView.builder utiliza evaluación perezosa en el Viewport, construyendo solo los widgets visibles y reciclando memoria al deslizar.
  • ListView.separated es el método canónico para inyectar divisores visuales limpios entre elementos de una colección.
  • Fijar itemExtent ahorra el cálculo de geometría a Flutter y acelera el scroll a 120 fps sostenidos.
  • Evita shrinkWrap: true en listas extensas para no anular el aislamiento del Viewport.
  • GridView.builder maqueta mallas bidimensionales mediante delegados de conteo fijo o extensión máxima adaptativa.
Capítulo 08

Botones, gestos y feedback táctil

Aprende a capturar la interacción del usuario con rigor técnico: la jerarquía de botones en Material 3 (Filled, ElevatedButton, Outlined y TextButton), personalización avanzada con ButtonStyle, detección de gestos complejos con GestureDetector y el feedback táctil con ondas de tinta (InkWell).

  • Dominar la jerarquía de botones en Material 3: cuándo usar FilledButton, ElevatedButton, OutlinedButton o TextButton.
  • Personalizar estilos sin fricción: aplicar colores, redondeos y paddings mediante styleFrom().
  • Controlar el estado deshabilitado: el mecanismo idiomático de Flutter con onPressed: null.
  • Diferenciar GestureDetector de InkWell: ondas táctiles (ripple effect) sobre Material frente a detectores de gestos puros (doble toque, arrastre y pulsación larga).

1. La Jerarquía de Botones en Material 3

En Material 3, los botones no son meros contenedores con eventos de clic: representan una jerarquía visual de énfasis que guía la mirada y la intención del usuario en la pantalla:

Widget Nivel de Énfasis Apariencia M3 Caso de Uso Recomendado
FilledButton Énfasis Máximo Color de fondo sólido primario, sin sombra de elevación. La acción final y determinante de una pantalla (ej: «Pagar Consulta», «Guardar Paciente»).
FilledButton.tonal Énfasis Alto-Medio Fondo con tono secundario suave y menor contraste. Acciones importantes que no deben competir con el botón primario principal.
ElevatedButton Énfasis Medio Superficie elevada con sombra proyectada sutil. Acciones en pantallas planas con fondos dinámicos o listas con relieve.
OutlinedButton Énfasis Medio-Bajo Borde perimetral sin relleno de fondo en reposo. Acciones alternativas directas (ej: «Descargar Receta», «Ver Historial»).
TextButton Énfasis Mínimo Texto puro sin borde ni fondo visible. Opciones de cancelación, enlaces de pie o dentro de cuadros de diálogo.

2. Personalización con styleFrom() y Estado Deshabilitado

Para personalizar botones en Flutter se utiliza el helper estático styleFrom(), evitando la complejidad innecesaria de construir un ButtonStyle desde cero:

FilledButton.icon(
  // Si onPressed es null, Flutter deshabilita el botón automáticamente
  onPressed: formularioValido ? () => confirmarCita() : null,
  icon: const Icon(Icons.check_circle_outline),
  label: const Text('Confirmar Agendamiento'),
  style: FilledButton.styleFrom(
    backgroundColor: const Color(0xFF02569B),
    foregroundColor: Colors.white,
    padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
  ),
);

¿Cómo deshabilitar un botón idiomáticamente?

En Flutter no existe una propiedad booleana disabled: true. La convención obligatoria del framework consiste en pasar null al parámetro onPressed. Automáticamente el botón adopta la opacidad reducida y los colores deshabilitados de la paleta de Material 3.

3. Feedback Táctil: InkWell vs GestureDetector

Cuando deseas que un elemento gráfico ordinario (como un Container, un Card o una imagen) reaccione a toques del usuario, dispones de dos mecanismos con propósitos arquitectónicos muy distintos:

InkWell (Feedback Material)

Produce el clásico efecto de ondas de tinta líquida (splash ripple) que emana desde el punto exacto donde el usuario apoya el dedo. Requiere que exista un ancestro Material para pintar la tinta.

Material(
  color: Colors.transparent,
  child: InkWell(
    borderRadius: BorderRadius.circular(12),
    splashColor: Colors.blueAccent.withOpacity(0.3),
    onTap: () => abrirDetalle(),
    child: const Padding(
      padding: EdgeInsets.all(16),
      child: Text('Elemento con onda táctil'),
    ),
  ),
);

GestureDetector (Gestos Puros)

No produce efectos visuales por sí mismo. Es un detector de eventos crudos de bajo nivel que reconoce doble toque, pulsación sostenida, deslizamiento (swipe) y arrastre (drag).

GestureDetector(
  onTap: () => registrarToque(),
  onDoubleTap: () => darMeGusta(),
  onLongPress: () => mostrarMenuContextual(),
  onPanUpdate: (detalles) {
    // detalles.delta.dx contiene el arrastre en X
  },
  child: const Icon(Icons.touch_app, size: 48),
);

Puntos clave del capítulo

  • Material 3 establece una jerarquía clara: FilledButton para acciones primarias, OutlinedButton para secundarias y TextButton para terciarias.
  • Usa styleFrom() en los constructores de botones para configurar colores, rellenos y bordes de forma concisa.
  • Para deshabilitar cualquier botón en Flutter, asigna onPressed: null.
  • Usa InkWell cuando requieras la animación visual de ondas táctiles sobre una superficie de Material.
  • Usa GestureDetector para eventos gestuales complejos como doble toque, pulsación larga o arrastre libre.
Capítulo 09

Formularios y validación de entradas

Aprende a capturar y auditar datos de usuario con robustez: la tríada Form, GlobalKey y TextFormField, reglas de validación reactivas con expresiones regulares, gestión de controladores (TextEditingController), flujo de foco con FocusNode y el control profesional del teclado móvil.

  • Implementar la arquitectura Form: orquestar el estado con GlobalKey<FormState> y validaciones centralizadas.
  • Construir validadores puros: validar DNI de 8 dígitos, correos electrónicos y contraseñas con expresiones regulares.
  • Manejar controladores y memoria: ciclo de vida de TextEditingController y la regla obligatoria de dispose().
  • Optimizar la experiencia del teclado móvil: TextInputType, TextInputAction, saltos de foco con FocusNode y cierre automático con unfocus().

1. La Tríada de Formularios: Form, GlobalKey y TextFormField

En aplicaciones reales no se utilizan campos TextField aislados para registrar pacientes o procesar pagos. Se utiliza la arquitectura de Form, la cual conecta múltiples campos bajo una sola llave global (GlobalKey) que permite disparar validaciones atómicas en bloque:

class FormularioPacienteState extends State<FormularioPaciente> {
  // 1. Llave global que referencia el estado del formulario
  final _formKey = GlobalKey<FormState>();

  // 2. Controladores de texto para leer y mutar valores
  late final TextEditingController _nombreCtrl;
  late final TextEditingController _dniCtrl;
  late final TextEditingController _emailCtrl;

  @override
  void initState() {
    super.initState();
    _nombreCtrl = TextEditingController();
    _dniCtrl = TextEditingController();
    _emailCtrl = TextEditingController();
  }

  @override
  void dispose() {
    // 3. Regla de oro: liberar siempre los controladores en dispose
    _nombreCtrl.dispose();
    _dniCtrl.dispose();
    _emailCtrl.dispose();
    super.dispose();
  }

  void _enviarFormulario() {
    // 4. validate() dispara todas las funciones validator del árbol
    if (_formKey.currentState!.validate()) {
      // Si todos los campos son válidos, procedemos al guardado
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Guardando paciente en base de datos...')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          // Campos TextFormField integrados...
        ],
      ),
    );
  }
}

2. Reglas de Validación con Expresiones Regulares

Cada TextFormField recibe una función pura en su propiedad validator: si retorna una cadena de texto (String), Flutter pinta el mensaje de error en color rojo debajo del campo; si retorna null, la validación se considera exitosa:

// 1. Campo Nombre (no vacío)
TextFormField(
  controller: _nombreCtrl,
  decoration: const InputDecoration(
    labelText: 'Nombres y Apellidos *',
    prefixIcon: Icon(Icons.person),
    border: OutlineInputBorder(),
  ),
  validator: (valor) {
    if (valor == null || valor.trim().isEmpty) {
      return 'El nombre es obligatorio';
    }
    return null;
  },
),

const SizedBox(height: 16),

// 2. Campo DNI (Exactamente 8 dígitos numéricos peruanos)
TextFormField(
  controller: _dniCtrl,
  keyboardType: TextInputType.number,
  decoration: const InputDecoration(
    labelText: 'Documento Nacional de Identidad (DNI) *',
    prefixIcon: Icon(Icons.badge),
    border: OutlineInputBorder(),
  ),
  validator: (valor) {
    if (valor == null || valor.trim().isEmpty) {
      return 'Ingrese el número de DNI';
    }
    final dniRegex = RegExp(r'^\d{8}$');
    if (!dniRegex.hasMatch(valor.trim())) {
      return 'El DNI debe contener exactamente 8 dígitos numéricos';
    }
    return null;
  },
),

const SizedBox(height: 16),

// 3. Campo Correo Electrónico
TextFormField(
  controller: _emailCtrl,
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(
    labelText: 'Correo Electrónico *',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),
  validator: (valor) {
    if (valor == null || valor.trim().isEmpty) {
      return 'Ingrese un correo electrónico';
    }
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!emailRegex.hasMatch(valor.trim())) {
      return 'Formato de correo no válido';
    }
    return null;
  },
),

3. Gestión Profesional del Teclado y Foco (FocusNode)

En dispositivos móviles, obligar al usuario a tocar manualmente cada campo en pantalla resulta tedioso. Para brindar una experiencia de usuario fluida se utiliza FocusNode para encadenar campos con el botón "Siguiente" () del teclado virtual:

// Declarar los nodos de foco en la clase State
final _nodoDni = FocusNode();
final _nodoEmail = FocusNode();

// En el primer campo:
TextFormField(
  controller: _nombreCtrl,
  textInputAction: TextInputAction.next, // Muestra botón "Siguiente" en el teclado
  onFieldSubmitted: (_) {
    // Salta automáticamente al campo DNI
    FocusScope.of(context).requestFocus(_nodoDni);
  },
);

// En el segundo campo:
TextFormField(
  controller: _dniCtrl,
  focusNode: _nodoDni,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) {
    FocusScope.of(context).requestFocus(_nodoEmail);
  },
);

4. Cerrar el Teclado al Tocar Fuera de la Pantalla

Un reclamo habitual en apps móviles es que el teclado virtual permanece abierto tapando botones de confirmación incluso cuando el usuario pulsa sobre zonas vacías de la pantalla. El patrón canónico para resolverlo en Flutter consiste en envolver el cuerpo de la vista en un GestureDetector con unfocus():

// Al tocar cualquier área vacía, el teclado se retrae suavemente
GestureDetector(
  onTap: () => FocusScope.of(context).unfocus(),
  child: Scaffold(
    appBar: AppBar(title: const Text('Registro')),
    body: SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Form(
        key: _formKey,
        child: Column(
          children: [
            // Campos de formulario...
          ],
        ),
      ),
    ),
  ),
);

Puntos clave del capítulo

  • Form centraliza la gestión del formulario y se controla externamente mediante una GlobalKey<FormState>.
  • El método _formKey.currentState!.validate() ejecuta las validaciones de todos los TextFormField en cascada.
  • Todo TextEditingController instanciado debe ser liberado obligatoriamente en el método dispose() para erradicar fugas de memoria.
  • Usa textInputAction: TextInputAction.next y FocusNode para guiar al usuario campo por campo con el teclado.
  • Envuelve tu pantalla en GestureDetector(onTap: () => FocusScope.of(context).unfocus()) para retraer el teclado al tocar fuera.
Capítulo 10

Diálogos modales, alertas y pestañas

Aprende a gestionar la atención y la navegación contextual del usuario: cuadros de diálogo de confirmación tipados con AlertDialog, menús modales con SimpleDialog, pestañas sincronizadas con TabBar y TabBarView, y la persistencia de vistas con AutomaticKeepAliveClientMixin.

  • Lanzar alertas de confirmación: showDialog<bool> y AlertDialog con retorno asíncrono tipado.
  • Construir selectores modales: menús de selección rápida con SimpleDialog y SimpleDialogOption.
  • Dominar el sistema de pestañas: DefaultTabController frente al control programático con TabController.
  • Evitar la pérdida de estado en pestañas: conservar datos de scroll y formularios con AutomaticKeepAliveClientMixin.

1. Cuadros de Diálogo de Confirmación: AlertDialog

En Flutter, los cuadros de diálogo son funciones asíncronas que se abren sobre la ruta actual mediante showDialog<T>(). Al cerrarse, devuelven un valor fuertemente tipado a través de Navigator.pop(context, resultado):

Future<void> confirmarCancelacionCita(BuildContext context, int citaId) async {
  // 1. showDialog retorna un Future con el valor devuelto en Navigator.pop
  final bool? confirmar = await showDialog<bool>(
    context: context,
    // barrierDismissible: false impide cerrar el diálogo pulsando fuera
    barrierDismissible: false,
    builder: (BuildContext ctx) {
      return AlertDialog(
        icon: const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 36),
        title: const Text('¿Cancelar Cita Médica?'),
        content: Text('Esta acción liberará el turno #$citaId para otro paciente.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false), // Retorna false
            child: const Text('Conservar Cita'),
          ),
          FilledButton(
            style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
            onPressed: () => Navigator.pop(ctx, true), // Retorna true
            child: const Text('Sí, Cancelar'),
          ),
        ],
      );
    },
  );

  // 2. Evaluamos la decisión del usuario de forma asíncrona
  if (confirmar == true && context.mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('La cita médica ha sido cancelada')),
    );
  }
}

2. Selectores Rápidos con SimpleDialog

Para presentar una lista de opciones exclusivas (como elegir el médico de turno o el método de pago) se emplea SimpleDialog con opciones SimpleDialogOption:

Future<String?> seleccionarMetodoPago(BuildContext context) async {
  return await showDialog<String>(
    context: context,
    builder: (BuildContext ctx) {
      return SimpleDialog(
        title: const Text('Seleccione método de pago'),
        children: [
          SimpleDialogOption(
            onPressed: () => Navigator.pop(ctx, 'EFECTIVO'),
            child: const ListTile(leading: Icon(Icons.money), title: Text('Efectivo en caja')),
          ),
          SimpleDialogOption(
            onPressed: () => Navigator.pop(ctx, 'TARJETA'),
            child: const ListTile(leading: Icon(Icons.credit_card), title: Text('Tarjeta Débito/Crédito')),
          ),
          SimpleDialogOption(
            onPressed: () => Navigator.pop(ctx, 'YAPE_PLIN'),
            child: const ListTile(leading: Icon(Icons.qr_code), title: Text('Billetera Digital (Yape / Plin)')),
          ),
        ],
      );
    },
  );
}

3. Sistema de Pestañas: DefaultTabController vs TabController

El sistema de pestañas en Flutter se compone de dos widgets gemelos perfectamente sincronizados:

  • TabBar: la barra de botones superior con el indicador deslizante.
  • TabBarView: el contenedor de pantallas que conmuta las vistas mediante deslizamiento horizontal (*swipe*).
class PantallaCitasTabs extends StatelessWidget {
  const PantallaCitasTabs({super.key});

  @override
  Widget build(BuildContext context) {
    // DefaultTabController administra el estado de las 3 pestañas automáticamente
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Consultas Médicas'),
          bottom: const TabBar(
            indicatorSize: TabBarIndicatorSize.tab,
            tabs: [
              Tab(icon: Icon(Icons.schedule), text: 'Pendientes'),
              Tab(icon: Icon(Icons.check_circle_outline), text: 'Atendidas'),
              Tab(icon: Icon(Icons.highlight_off), text: 'Canceladas'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            VistaCitasPendientes(),
            VistaCitasAtendidas(),
            VistaCitasCanceladas(),
          ],
        ),
      ),
    );
  }
}

4. Persistencia de Vistas con AutomaticKeepAliveClientMixin

Por defecto, TabBarView destruye de memoria los widgets de las pestañas inactivas para ahorrar RAM. El problema es que si el usuario había hecho scroll hasta el elemento 50 o estaba llenando un formulario, al volver a esa pestaña la vista se reconstruye desde cero.

Para ordenar a Flutter que conserve viva la vista en memoria utilizamos AutomaticKeepAliveClientMixin:

class VistaCitasPendientes extends StatefulWidget {
  const VistaCitasPendientes({super.key});

  @override
  State<VistaCitasPendientes> createState() => _VistaCitasPendientesState();
}

// Aplicamos el mixin AutomaticKeepAliveClientMixin en la clase State
class _VistaCitasPendientesState extends State<VistaCitasPendientes>
    with AutomaticKeepAliveClientMixin {

  // 1. Declarar wantKeepAlive en true para preservar el estado
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    // 2. Es obligatorio invocar super.build(context) como primera línea
    super.build(context);

    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, i) => ListTile(title: Text('Cita #$i (Preservada)')),
    );
  }
}

Puntos clave del capítulo

  • showDialog<T> es una función asíncrona que retorna valores tipados mediante Navigator.pop(context, valor).
  • Configura barrierDismissible: false cuando requieras que el usuario elija obligatoriamente una opción de diálogo sin pulsar fuera.
  • Usa SimpleDialog para selecciones de lista únicas y limpias.
  • DefaultTabController sincroniza el encabezado TabBar con el cuerpo deslizante TabBarView sin controladores manuales.
  • Implementa AutomaticKeepAliveClientMixin con wantKeepAlive => true para evitar que las pestañas pierdan su estado o scroll al cambiar de vista.
Capítulo 11

Navegación básica e imperativa (Navigator 1.0)

Comprende el modelo de pila LIFO (Last-In, First-Out) que rige las pantallas en Flutter: transiciones nativas con MaterialPageRoute, transferencia de parámetros tipados, retorno asíncrono de resultados con Navigator.pop, manipulación del historial y protección contra salidas accidentales con PopScope.

  • Gestionar la pila de rutas: apilar y desapilar pantallas con Navigator.push() y Navigator.pop().
  • Transferencia tipada de datos: inyección de argumentos por constructor y captura de retornos con await.
  • Control de flujo e historial: reemplazar rutas con pushReplacement() y vaciar la pila con pushAndRemoveUntil().
  • Intercepción de retroceso moderno: control de salida y confirmación de abandono con PopScope (Flutter 3.22+).

1. La Pila de Rutas (Stack LIFO)

En Flutter, las pantallas o vistas se denominan Routes y residen dentro de una pila de navegación gestionada por el widget Navigator. El funcionamiento sigue el principio LIFO (Last-In, First-Out): la última pantalla apilada es la visible para el usuario, y al pulsar el botón de retroceso se desapila para revelar la anterior.

// Comprobar si el usuario puede retroceder en la pila actual
if (Navigator.canPop(context)) {
  Navigator.pop(context);
}

2. Navegación hacia Adelante con MaterialPageRoute

Para abrir una nueva pantalla utilizamos Navigator.push(), envolviendo el widget de destino en un MaterialPageRoute. Esta clase se encarga de aplicar la animación de transición nativa de la plataforma (deslizamiento lateral en iOS o elevación/desvanecimiento suave en Android Material 3):

import 'package:flutter/material.dart';

// Pantalla Origen (Lista de Médicos)
class PantallaMedicos extends StatelessWidget {
  const PantallaMedicos({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Directorio Médico')),
      body: Center(
        child: FilledButton.icon(
          icon: const Icon(Icons.person),
          label: const Text('Ver Ficha del Dr. Morales'),
          onPressed: () {
            // Navegamos pasando parámetros tipados directamente al constructor
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext ctx) => const PantallaDetalleMedico(
                  medicoId: 104,
                  nombre: 'Dr. Roberto Morales',
                  especialidad: 'Cardiología',
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}
Seguridad de Tipos en Tiempo de Compilación

Evita usar rutas mágicas con cadenas de texto (Navigator.pushNamed('/detalle')) en proyectos grandes. Pasar datos mediante los constructores de tus widgets garantiza que si un parámetro cambia de tipo o nombre, el compilador de Dart detectará el error de inmediato sin fallar en tiempo de ejecución.

3. Retorno Asíncrono de Datos con Navigator.pop y await

El método Navigator.push<T>() devuelve un Future<T?> que se resuelve cuando la pantalla apilada se cierra mediante Navigator.pop<T>(context, valor). Esto permite construir flujos interactivos como selectores, filtros o formularios de edición con total tipado:

// Pantalla Destino que solicita una acción y retorna un booleano
class PantallaConfirmarTurno extends StatelessWidget {
  final int turnoId;
  const PantallaConfirmarTurno({super.key, required this.turnoId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Confirmar Turno #$turnoId')),
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            OutlinedButton(
              onPressed: () => Navigator.pop(context, false), // Retorna false
              child: const Text('Rechazar'),
            ),
            const SizedBox(width: 16),
            FilledButton(
              onPressed: () => Navigator.pop(context, true), // Retorna true
              child: const Text('Aceptar Turno'),
            ),
          ],
        ),
      ),
    );
  }
}

// Invocación asíncrona desde la pantalla principal
Future<void> solicitarAprobacion(BuildContext context, int turnoId) async {
  // 1. Esperamos la respuesta fuertemente tipada como bool?
  final bool? resultado = await Navigator.push<bool>(
    context,
    MaterialPageRoute(
      builder: (context) => PantallaConfirmarTurno(turnoId: turnoId),
    ),
  );

  // 2. Regla de oro: verificar context.mounted tras cualquier pausa asíncrona
  if (!context.mounted) return;

  if (resultado == true) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('¡Turno médico confirmado exitosamente!')),
    );
  }
}

4. Manipulación Avanzada de la Pila

En ciertos flujos de la aplicación es imprescindible reemplazar pantallas o vaciar el historial por completo:

Método Comportamiento de la Pila Caso de Uso Típico
pushReplacement() Elimina la ruta actual e inserta la nueva en el mismo nivel. El usuario no puede regresar a la anterior. Pantalla de Splash o Bienvenida hacia el Login; completar un paso irreversible de un wizard.
pushAndRemoveUntil() Elimina rutas previas según un predicado lógico (o vacía todas) y sitúa la nueva como raíz. Cerrar sesión (logout) para volver al Login sin historial; finalizar una compra/checkout.
// Cerrar sesión y vaciar completamente la pila de rutas
void cerrarSesion(BuildContext context) {
  Navigator.pushAndRemoveUntil(
    context,
    MaterialPageRoute(builder: (context) => const PantallaLogin()),
    (Route<dynamic> route) => false, // El predicado false destruye todo el historial previo
  );
}

5. Intercepción de Retroceso con PopScope (Flutter 3.22+)

Históricamente se utilizaba el widget WillPopScope para interceptar el botón Atrás de Android o el gesto de deslizamiento de iOS. A partir de Flutter 3.12 y consolidado en Flutter 3.22+, PopScope es el estándar oficial que ofrece compatibilidad plena con la navegación predictiva por gestos de Android 14+:

class FormularioPacienteConGuardia extends StatefulWidget {
  const FormularioPacienteConGuardia({super.key});

  @override
  State<FormularioPacienteConGuardia> createState() => _FormularioPacienteConGuardiaState();
}

class _FormularioPacienteConGuardiaState extends State<FormularioPacienteConGuardia> {
  bool _hayCambiosSinGuardar = true;

  Future<bool> _mostrarDialogoConfirmacion() async {
    final bool? abandonar = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('¿Descartar cambios?'),
        content: const Text('Tienes datos ingresados que se perderán si sales ahora.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: const Text('Continuar editando'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(ctx, true),
            child: const Text('Descartar y salir'),
          ),
        ],
      ),
    );
    return abandonar ?? false;
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
      // Si no hay cambios sin guardar, permitimos el pop directo (canPop: true).
      // Si hay cambios, bloqueamos el pop automático (canPop: false) para disparar el diálogo.
      canPop: !_hayCambiosSinGuardar,
      onPopInvokedWithResult: (bool didPop, dynamic result) async {
        // Si el pop ya se ejecutó con éxito por el sistema, no hacemos nada
        if (didPop) return;

        // Si fue bloqueado, consultamos al usuario
        final bool seguro = await _mostrarDialogoConfirmacion();
        if (seguro && context.mounted) {
          // Desactivamos la guardia y ejecutamos el pop manualmente
          setState(() => _hayCambiosSinGuardar = false);
          Navigator.pop(context);
        }
      },
      child: Scaffold(
        appBar: AppBar(title: const Text('Registro Clínico')),
        body: const Center(
          child: Text('Formulario con cambios pendientes de guardar...'),
        ),
      ),
    );
  }
}

6. Transiciones de Pantalla y Animaciones Compartidas con Hero

Mientras que MaterialPageRoute proporciona la animación por defecto del sistema operativo (deslizamiento horizontal en iOS o zoom/elevación en Android), Flutter permite personalizar completamente las curvas de transición entre pantallas mediante PageRouteBuilder y conectar elementos visuales mediante el widget Hero:

Directorio Médico RM tag: 'dr-morales' Pantalla A (Origen) Hero en posición inicial RM Capa Superior: Overlay 1. Desacople espacial Flutter extrae el widget al Overlay 2. Interpolación curva Anima posición (X,Y) y tamaño (R) 3. Retorno simétrico Al hacer pop(), viaja en reversa Navigator.push() → ← Navigator.pop() Ficha del Médico RM tag: 'dr-morales' Reservar Consulta Pantalla B (Destino) Hero expandido en destino
Figura 11.1: Animación de transición de elemento compartido con el widget Hero: Flutter extrae automáticamente el widget coincidente por su tag hacia el Overlay, calculando la trayectoria espacial y la escala entre ambas rutas en el push y su retorno simétrico en el pop.

7. Código de Implementación: Hero y PageRouteBuilder

Para lograr este efecto visual de alta gama, envolvemos el widget origen y el widget destino con Hero, compartiendo exactamente la misma etiqueta (tag):

// EN PANTALLA A: Avatar pequeño en la lista
ListTile(
  leading: Hero(
    tag: 'avatar-dr-morales', // Identificador único compartido
    child: CircleAvatar(
      radius: 20,
      backgroundColor: Colors.teal,
      child: Text('RM', style: TextStyle(color: Colors.white)),
    ),
  ),
  title: const Text('Dr. Roberto Morales'),
  subtitle: const Text('Cardiología'),
  onTap: () {
    // Navegación con animación personalizada de deslizamiento suave (Slide)
    Navigator.push(
      context,
      PageRouteBuilder(
        transitionDuration: const Duration(milliseconds: 500),
        pageBuilder: (context, animacion, animacionSecundaria) {
          return const PantallaFichaDetalleMedico();
        },
        transitionsBuilder: (context, animacion, animacionSecundaria, child) {
          // Curva de aceleración natural
          final curva = CurvedAnimation(parent: animacion, curve: Curves.easeInOutCubic);
          return SlideTransition(
            position: Tween<Offset>(begin: const Offset(0.0, 0.1), end: Offset.zero).animate(curva),
            child: FadeTransition(opacity: curva, child: child),
          );
        },
      ),
    );
  },
);

// EN PANTALLA B: Cabecera expandida en la vista de detalle
class PantallaFichaDetalleMedico extends StatelessWidget {
  const PantallaFichaDetalleMedico({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Ficha Médica')),
      body: Center(
        child: Column(
          children: [
            const SizedBox(height: 24),
            // El mismo tag hace que el avatar "vuele" desde la lista hasta aquí
            Hero(
              tag: 'avatar-dr-morales',
              child: CircleAvatar(
                radius: 60,
                backgroundColor: Colors.teal,
                child: Text('RM', style: TextStyle(fontSize: 36, color: Colors.white)),
              ),
            ),
            const SizedBox(height: 16),
            const Text('Dr. Roberto Morales', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
          ],
        ),
      ),
    );
  }
}

Puntos clave del capítulo

  • El modelo Navigator gestiona las pantallas en una estructura de pila LIFO sobre un Overlay.
  • MaterialPageRoute adapta la transición visual automáticamente al sistema operativo (Android o iOS).
  • Pasa datos mediante argumentos tipados en el constructor del widget para beneficiarte del análisis estático de Dart.
  • await Navigator.push<T>() captura de forma asíncrona los valores devueltos por Navigator.pop<T>(context, valor).
  • Usa pushAndRemoveUntil() para resetear el historial tras un inicio o cierre de sesión.
  • PopScope con onPopInvokedWithResult reemplaza al obsoleto WillPopScope y garantiza compatibilidad con gestos predictivos modernos.
  • El widget Hero junto con PageRouteBuilder permite crear transiciones espaciales de elementos compartidos de máxima fluidez visual vinculando widgets con una tag idéntica.
Capítulo 12

Navegación declarativa con GoRouter

Adopta el estándar oficial de navegación moderna respaldado por el equipo de Flutter: enrutamiento basado en URLs y paths, soporte nativo de Deep Linking para Android/iOS y sincronización web, paso de parámetros de ruta y consulta, protección de rutas (guards) y gestión centralizada de páginas 404.

  • Configurar MaterialApp.router: vincular el motor declarativo GoRouter a la raíz del proyecto.
  • Estructurar el árbol de rutas: definir rutas principales y sub-rutas anidadas con GoRoute.
  • Extraer parámetros tipados: leer pathParameters y queryParameters desde GoRouterState.
  • Dominar los verbos de navegación: diferencias clave entre context.go(), context.push() y context.goNamed().
  • Implementar guardias de autenticación: redirección automática global mediante la función redirect.

1. ¿Por qué GoRouter frente a Navigator 1.0?

En aplicaciones profesionales multiplataforma (iOS, Android, Web y Escritorio), el modelo imperativo de Navigator.push presenta limitaciones críticas: no soporta la barra de direcciones del navegador web, no gestiona enlaces profundos (Deep Links) provenientes de notificaciones o correos, y dificulta la protección centralizada de rutas protegidas. go_router resuelve esto modelando la navegación de forma declarativa basada en URLs.

# Instalación de la versión oficial en pubspec.yaml
flutter pub add go_router

2. Configuración Base con MaterialApp.router

Para habilitar GoRouter, reemplazamos el constructor habitual de MaterialApp por MaterialApp.router y le asignamos una instancia configurada de GoRouter:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const MiAppClinica());
}

// 1. Instanciamos el enrutador con su configuración de rutas
final GoRouter _appRouter = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      name: 'inicio',
      builder: (BuildContext context, GoRouterState state) => const PantallaInicio(),
      routes: [
        // Sub-ruta anidada: la URL resultante será '/pacientes'
        GoRoute(
          path: 'pacientes',
          name: 'lista-pacientes',
          builder: (context, state) => const PantallaPacientes(),
        ),
      ],
    ),
  ],
);

// 2. Conectamos GoRouter a la raíz de la aplicación
class MiAppClinica extends StatelessWidget {
  const MiAppClinica({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Clínica San Juan',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
      routerConfig: _appRouter,
    );
  }
}

3. Parámetros de Ruta y de Consulta (Path & Query Params)

A través del objeto GoRouterState podemos capturar segmentos dinámicos de la URL (ej. /pacientes/:id) y parámetros de consulta de tipo clave-valor (ej. /citas?estado=pendiente):

final GoRouter _routerConParametros = GoRouter(
  routes: [
    GoRoute(
      path: '/paciente/:id',
      name: 'detalle-paciente',
      builder: (BuildContext context, GoRouterState state) {
        // 1. Parámetro de ruta obligatorio (Path Parameter)
        final String pacienteId = state.pathParameters['id'] ?? '0';

        // 2. Parámetro de consulta opcional (Query Parameter desde URI)
        final String? pestanaInicial = state.uri.queryParameters['tab'];

        return PantallaDetallePaciente(
          id: int.parse(pacienteId),
          tabActiva: pestanaInicial ?? 'historial',
        );
      },
    ),
  ],
);

4. Métodos de Navegación: go() frente a push()

Una de las dudas más frecuentes al usar GoRouter es comprender cuándo utilizar context.go() y cuándo context.push():

Método Acción sobre la Pila Efecto en la URL Web Caso de Uso Recomendado
context.go('/ruta') Reemplaza la jerarquía completa por la ruta declarada según su posición en el árbol. Sincroniza la URL exacta en el navegador. Navegación principal, tabs, menús laterales, enlaces profundos.
context.push('/ruta') Apila una pantalla encima de la actual sin alterar el árbol base. Añade un escalón a la pila del navegador. Flujos modales, vistas de previsualización temporal o formularios emergentes.
context.goNamed('nombre') Navega por el nombre único de la ruta pasando un mapa de parámetros. Construye la URL automáticamente según la definición. Mejor práctica para no depender de cadenas de texto rígidas si cambias la estructura de URLs.
// Navegación nombrada con parámetros tipados
void verDetallePaciente(BuildContext context, int id) {
  context.goNamed(
    'detalle-paciente',
    pathParameters: {'id': id.toString()},
    queryParameters: {'tab': 'recetas'},
  );
}

// Cerrar la vista actual o retroceder
void volverAtras(BuildContext context) {
  if (context.canPop()) {
    context.pop();
  }
}

5. Guardias de Autenticación y Redirección Global

La propiedad redirect de GoRouter evalúa las condiciones de la aplicación antes de renderizar cualquier vista. Si el usuario no ha iniciado sesión, es redirigido automáticamente a la pantalla de acceso sin permitir fugas de seguridad:

bool usuarioAutenticado = false; // Estado global o servicio de auth

final GoRouter _routerSeguro = GoRouter(
  initialLocation: '/',
  redirect: (BuildContext context, GoRouterState state) {
    final bool enPantallaLogin = state.matchedLocation == '/login';

    // 1. Si no está autenticado y no está en login, redirigir a /login
    if (!usuarioAutenticado && !enPantallaLogin) {
      return '/login';
    }

    // 2. Si ya está autenticado e intenta ir a /login, redirigir al panel principal
    if (usuarioAutenticado && enPantallaLogin) {
      return '/';
    }

    // 3. Retornar null significa "permitir el paso sin redirección"
    return null;
  },
  routes: [
    GoRoute(path: '/login', builder: (ctx, state) => const PantallaLogin()),
    GoRoute(path: '/', builder: (ctx, state) => const PantallaDashboard()),
  ],
  // 4. Gestión centralizada de errores y rutas no encontradas (404)
  errorBuilder: (context, state) => Scaffold(
    appBar: AppBar(title: const Text('Página no encontrada')),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.error_outline, size: 64, color: Colors.redAccent),
          const SizedBox(height: 16),
          Text('Error 404: Ruta desconocida (${state.matchedLocation})'),
          const SizedBox(height: 24),
          FilledButton(
            onPressed: () => context.go('/'),
            child: const Text('Volver al inicio'),
          ),
        ],
      ),
    ),
  ),
);

Puntos clave del capítulo

  • GoRouter es la solución oficial y recomendada para enrutamiento declarativo en Flutter.
  • Conecta el enrutador a la app mediante el constructor MaterialApp.router(routerConfig: ...).
  • Usa state.pathParameters para variables de segmento y state.uri.queryParameters para filtros opcionales.
  • Prefiere context.goNamed() para desacoplar el código de navegación de la estructura física de URLs.
  • La función redirect centraliza la seguridad y los permisos de acceso en un único punto auditable.
  • Provee siempre un errorBuilder para ofrecer una experiencia elegante ante rutas inexistentes (404).
Capítulo 13

Menús de navegación y vistas múltiples

Estructura la navegación principal de tu aplicación para móvil, tablet y escritorio: implementa la barra inferior NavigationBar de Material 3, menús laterales NavigationDrawer, comprende el dilema de la destrucción de estado entre pantallas y soluciónalo con IndexedStack y StatefulShellRoute de GoRouter.

  • Implementar la barra inferior Material 3: NavigationBar con píldoras de selección y destinos semánticos.
  • Construir menús laterales expansivos: NavigationDrawer para navegación en paneles principales y tablets.
  • Diagnosticar la pérdida de estado: entender por qué alternar el body de un Scaffold destruye tus vistas.
  • Preservar vistas en memoria: sincronizar pantallas montadas con IndexedStack.
  • Rutas anidadas con GoRouter: crear pestañas con historial independiente usando StatefulShellRoute.indexedStack.

1. Barra de Navegación Inferior M3: NavigationBar

En Material 3, el antiguo widget BottomNavigationBar ha sido reemplazado por NavigationBar. Este componente incorpora indicadores visuales en forma de píldora (pill), soporta iconos diferenciados para los estados activo/inactivo y cumple con las directrices ergonómicas modernas de accesibilidad táctil:

class PantallaPrincipalM3 extends StatefulWidget {
  const PantallaPrincipalM3({super.key});

  @override
  State<PantallaPrincipalM3> createState() => _PantallaPrincipalM3State();
}

class _PantallaPrincipalM3State extends State<PantallaPrincipalM3> {
  int _indiceActual = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: NavigationBar(
        selectedIndex: _indiceActual,
        onDestinationSelected: (int nuevoIndice) {
          setState(() => _indiceActual = nuevoIndice);
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.calendar_month_outlined),
            selectedIcon: Icon(Icons.calendar_month),
            label: 'Citas',
          ),
          NavigationDestination(
            icon: Icon(Icons.people_outline),
            selectedIcon: Icon(Icons.people),
            label: 'Pacientes',
          ),
          NavigationDestination(
            icon: Icon(Icons.medication_outlined),
            selectedIcon: Icon(Icons.medication),
            label: 'Recetas',
          ),
        ],
      ),
      body: Center(
        child: Text('Vista seleccionada: $_indiceActual'),
      ),
    );
  }
}

2. Menú Lateral Material 3: NavigationDrawer

Para pantallas con mayor superficie o para agrupar secciones secundarias de administración, Material 3 ofrece NavigationDrawer. Se integra de manera natural tanto en la propiedad drawer de un Scaffold en dispositivos móviles como en layouts permanentes para tablets:

Drawer construirMenuLateral(BuildContext context, int indice, ValueChanged<int> onSelect) {
  return Drawer(
    child: NavigationDrawer(
      selectedIndex: indice,
      onDestinationSelected: (idx) {
        onSelect(idx);
        Navigator.pop(context); // Cierra el drawer al seleccionar
      },
      children: const [
        Padding(
          padding: EdgeInsets.fromLTRB(28, 16, 16, 10),
          child: Text('Portal Médico San Juan', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
        ),
        NavigationDrawerDestination(
          icon: Icon(Icons.dashboard_outlined),
          selectedIcon: Icon(Icons.dashboard),
          label: Text('Panel General'),
        ),
        NavigationDrawerDestination(
          icon: Icon(Icons.medical_services_outlined),
          selectedIcon: Icon(Icons.medical_services),
          label: Text('Consultas Activas'),
        ),
        Divider(indent: 16, endIndent: 16),
        NavigationDrawerDestination(
          icon: Icon(Icons.settings_outlined),
          selectedIcon: Icon(Icons.settings),
          label: Text('Ajustes y Perfil'),
        ),
      ],
    ),
  );
}

3. El Problema de la Pérdida de Estado entre Pestañas

Un error muy extendido al implementar barras de navegación es alternar el cuerpo del Scaffold utilizando una lista simple de widgets:

// ANTIPATRÓN: Destruye y recrea la vista en cada pulsación
body: _vistas[_indiceActual],
Consecuencias del Desmontaje de Vistas

Al cambiar el widget en el body, Flutter desmonta completamente el Element y su State. El usuario experimenta tres problemas graves: 1) Se pierde la posición de scroll en las listas; 2) Los datos no guardados en formularios o filtros desaparecen; 3) Se vuelven a disparar llamadas HTTP a la API cada vez que se regresa a la pestaña.

4. Solución 1: IndexedStack para Conservar Vistas en Memoria

El widget IndexedStack mantiene todos sus hijos montados permanentemente en el árbol de elementos. Solo dibuja y procesa eventos de interacción para el widget indicado en index, ocultando los demás sin destruirlos:

class PantallaConEstadoPreservado extends StatefulWidget {
  const PantallaConEstadoPreservado({super.key});

  @override
  State<PantallaConEstadoPreservado> createState() => _PantallaConEstadoPreservadoState();
}

class _PantallaConEstadoPreservadoState extends State<PantallaConEstadoPreservado> {
  int _indice = 0;

  // Las vistas hijas se conservarán intactas en memoria
  final List<Widget> _paginas = const [
    VistaListaCitas(),
    VistaDirectorioPacientes(),
    VistaHistorialRecetas(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _indice,
        children: _paginas,
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _indice,
        onDestinationSelected: (i) => setState(() => _indice = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.event), label: 'Citas'),
          NavigationDestination(icon: Icon(Icons.people), label: 'Pacientes'),
          NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Recetas'),
        ],
      ),
    );
  }
}

5. Solución 2: Rutas Anidadas con GoRouter (StatefulShellRoute)

En aplicaciones con GoRouter, la técnica profesional para construir barras de navegación persistentes con pilas de navegación independientes por cada pestaña es StatefulShellRoute.indexedStack. Esto permite que, si un usuario entra al detalle de un paciente en la pestaña 2 y luego salta a la pestaña 1, al regresar a la pestaña 2 siga exactamente donde estaba sin reiniciar la navegación:

final GoRouter _routerConPestanas = GoRouter(
  initialLocation: '/citas',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) {
        // navigationShell contiene la vista activa y gestiona el cambio de ramas
        return Scaffold(
          body: navigationShell,
          bottomNavigationBar: NavigationBar(
            selectedIndex: navigationShell.currentIndex,
            onDestinationSelected: (int index) {
              // Navegación con opción de regresar a la raíz de la pestaña si ya está activa
              navigationShell.goBranch(
                index,
                initialLocation: index == navigationShell.currentIndex,
              );
            },
            destinations: const [
              NavigationDestination(icon: Icon(Icons.calendar_today), label: 'Citas'),
              NavigationDestination(icon: Icon(Icons.group), label: 'Pacientes'),
            ],
          ),
        );
      },
      branches: [
        // Rama 1: Citas
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/citas',
              builder: (context, state) => const VistaListaCitas(),
            ),
          ],
        ),
        // Rama 2: Pacientes (con su propio sub-árbol de detalle)
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/pacientes',
              builder: (context, state) => const VistaDirectorioPacientes(),
              routes: [
                GoRoute(
                  path: 'detalle/:id',
                  builder: (context, state) => VistaFichaPaciente(id: state.pathParameters['id']!),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);

Puntos clave del capítulo

  • NavigationBar sustituye a BottomNavigationBar cumpliendo las directrices oficiales de Material 3.
  • Usa NavigationDrawer para interfaces en tablets o paneles de administración con múltiples secciones.
  • Reemplazar el body del Scaffold con un selector directo destruye el estado, perdiendo la posición de scroll y datos en formularios.
  • IndexedStack conserva en memoria todos los widgets hijos y solo pinta el seleccionado por índice.
  • StatefulShellRoute.indexedStack en GoRouter permite mantener barras de navegación globales con historial y pila propia en cada pestaña.
Capítulo 14

Estado efímero vs estado de aplicación (setState)

Distingue con claridad conceptual cuándo una variable pertenece únicamente a un widget local y cuándo debe compartirse entre pantallas: domina la mecánica interna de setState, identifica las trampas de sobre-reconstrucción que degradan los fotogramas y aprende a aislar componentes interactivos.

  • Clasificar el estado: diferenciar entre estado efímero (local) y estado de aplicación (global/compartido).
  • Mecánica interna de setState: cómo marca los elementos como sucios (dirty) y programa el siguiente frame de renderizado.
  • Evitar cuellos de botella: diagnosticar y erradicar la sobre-reconstrucción de árboles masivos de widgets.
  • Aislamiento quirúrgico: modularizar componentes interactivos y usar StatefulBuilder para optimizar la GPU.

1. Taxonomía del Estado en Flutter

En el desarrollo con Flutter, la documentación oficial divide el estado de una aplicación en dos grandes categorías según su ciclo de vida y su alcance de visibilidad:

Criterio Estado Efímero (Local / UI State) Estado de Aplicación (Shared / App State)
Definición Información contenida de forma exclusiva dentro de un único widget. Si el widget se desmonta, el dato puede descartarse sin consecuencias. Información que se comparte entre múltiples pantallas, persiste en sesiones o afecta la lógica de negocio central.
Ejemplos típicos Pestaña seleccionada en un TabBar, visibilidad de una contraseña (icono de ojo), estado de apertura de un acordeón, animación en curso. Usuario autenticado en la clínica, carrito de compras, listado de citas sincronizadas, ajustes de tema oscuro/claro y preferencias del sistema.
Herramienta ideal StatefulWidget con setState() o ValueNotifier local. Gestores de estado: Provider, Riverpod, Bloc.

2. Mecánica Interna de setState

Cuando invocas setState(() { ... }) dentro de la clase State, estás ejecutando dos acciones concretas: primero, modificas el valor sincrónico de las variables de instancia; segundo, notificas al framework mediante _element.markNeedsBuild(). Flutter marca ese nodo del árbol de elementos como dirty (sucio) y programa una llamada a su método build() en el siguiente refresco de pantalla (a 60 o 120 Hz).

class CampoPasswordSeguro extends StatefulWidget {
  const CampoPasswordSeguro({super.key});

  @override
  State<CampoPasswordSeguro> createState() => _CampoPasswordSeguroState();
}

class _CampoPasswordSeguroState extends State<CampoPasswordSeguro> {
  // Estado puramente efímero: solo le importa a este campo de texto
  bool _ocultarClave = true;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      obscureText: _ocultarClave,
      decoration: InputDecoration(
        labelText: 'Contraseña de Acceso',
        prefixIcon: const Icon(Icons.lock_outline),
        suffixIcon: IconButton(
          icon: Icon(_ocultarClave ? Icons.visibility_off : Icons.visibility),
          onPressed: () {
            // Modificamos el valor e indicamos que se requiere un nuevo frame
            setState(() {
              _ocultarClave = !_ocultarClave;
            });
          },
        ),
      ),
    );
  }
}
Regla de Oro: Prohibido código asíncrono dentro del callback de setState

El callback que recibe setState() debe ser estrictamente síncrono y rápido. Nunca ejecutes await, lecturas de bases de datos o peticiones HTTP dentro de setState(() { ... }). Resuelve la operación asíncrona primero, verifica if (!mounted) return; y finalmente asigna el resultado síncrono dentro de setState.

3. La Trampa de la Sobre-Reconstrucción

El error de rendimiento más común de los desarrolladores novatos es situar setState() en la raíz de un Scaffold que aloja cientos de widgets hijos estáticos:

// ANTIPATRÓN: Toda la pantalla se reconstruye cada vez que cambia el contador
class PantallaIneficienteState extends State<PantallaIneficiente> {
  int _contadorClicks = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Clínica Central')),
      body: Column(
        children: [
          // Banner costoso con imágenes y gradientes (se reconstruye sin necesidad)
          const BannerPublicitarioPesado(),
          
          // Gráficos y tablas estadísticas (se recalculan innecesariamente)
          const GraficoEstadisticasMensuales(),

          // El único elemento que realmente cambia de valor:
          Text('Clicks registrados: $_contadorClicks'),
          ElevatedButton(
            onPressed: () => setState(() => _contadorClicks++),
            child: const Text('Sumar'),
          ),
        ],
      ),
    );
  }
}

4. Aislamiento Quirúrgico del Estado

Para mantener la tasa de fotogramas a 60/120 fps constantes, la mejor práctica arquitectónica consiste en aislar el estado en un widget hijo independiente. De este modo, únicamente el nodo que cambia es marcado como sucio, dejando el resto de la interfaz completamente intacto:

// PATRÓN ÓPTIMO: Extraer el componente interactivo en su propia clase pequeña
class BotonContadorAislado extends StatefulWidget {
  const BotonContadorAislado({super.key});

  @override
  State<BotonContadorAislado> createState() => _BotonContadorAisladoState();
}

class _BotonContadorAisladoState extends State<BotonContadorAislado> {
  int _contador = 0;

  @override
  Widget build(BuildContext context) {
    // Al invocar setState aquí, solo se reconstruye este pequeño sub-árbol
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Clicks: $_contador', style: const TextStyle(fontWeight: FontWeight.bold)),
        const SizedBox(width: 12),
        FilledButton.tonal(
          onPressed: () => setState(() => _contador++),
          child: const Text('Incrementar'),
        ),
      ],
    );
  }
}

// Pantalla principal limpia: el método build es un StatelessWidget liviano
class PantallaEficiente extends StatelessWidget {
  const PantallaEficiente({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Portal Eficiente')),
      body: const Column(
        children: [
          BannerPublicitarioPesado(), // Widget constante: NUNCA se reconstruye
          GraficoEstadisticasMensuales(),
          Center(child: BotonContadorAislado()), // Único widget reactivo
        ],
      ),
    );
  }
}

Puntos clave del capítulo

  • El estado efímero le pertenece a un solo widget y se maneja con setState().
  • El estado de aplicación trasciende pantallas y requiere herramientas como Provider o Riverpod.
  • setState() marca el nodo del elemento como dirty para que el motor ejecute su método build() en el próximo frame.
  • Mantén el callback de setState() síncrono; resuelve los Futures fuera de él.
  • Nunca envuelvas una pantalla entera en un StatefulWidget masivo si solo cambia un botón o texto: extrae los componentes reactivos a widgets aislados para no saturar la GPU.
Capítulo 15

Estado compartido con Provider

Implementa una arquitectura escalable para compartir datos entre pantallas sin caer en el paso manual de parámetros (prop drilling): domina el patrón Observer con ChangeNotifier, la inyección reactiva con ChangeNotifierProvider y MultiProvider, y la distinción crítica entre context.watch, context.read y Consumer.

  • Definir modelos reactivos: encapsular la lógica de negocio con ChangeNotifier y notifyListeners().
  • Inyectar dependencias en el árbol: configurar ChangeNotifierProvider y MultiProvider.
  • Consumir estado con precisión: dominar context.watch() frente a context.read().
  • Reconstrucciones quirúrgicas: utilizar Consumer<T> optimizando el argumento child.

1. ¿Qué es Provider y por qué es el estándar inicial de Flutter?

En una aplicación real, múltiples pantallas alejadas entre sí necesitan acceder al mismo estado (por ejemplo, el carrito de farmacia o el usuario logueado en la clínica). Pasar esas variables manualmente a través de los constructores de decenas de widgets intermedios (*prop drilling*) ensucia el código y dificulta el mantenimiento. provider es una envoltura elegante sobre InheritedWidget que permite inyectar y escuchar objetos desde cualquier lugar del árbol de widgets con un rendimiento impecable.

# Instalación de provider en pubspec.yaml
flutter pub add provider

2. El Modelo de Negocio con ChangeNotifier

El primer paso consiste en crear una clase que extienda de ChangeNotifier. Cada vez que el estado interno cambia, se invoca notifyListeners() para avisar a todos los widgets suscritos que deben redibujarse con los nuevos datos:

import 'package:flutter/material.dart';

// Modelo de datos para las consultas del paciente
class ConsultaMedica {
  final int id;
  final String doctor;
  final double costo;
  const ConsultaMedica({required this.id, required this.doctor, required this.costo});
}

// Gestor de estado que notifica a la interfaz gráfica
class CarritoClinica with ChangeNotifier {
  final List<ConsultaMedica> _items = [];

  // Exponemos una lista inmutable para proteger el estado interno
  List<ConsultaMedica> get items => List.unmodifiable(_items);

  double get total => _items.fold(0.0, (acum, elem) => acum + elem.costo);

  void agregarConsulta(ConsultaMedica consulta) {
    _items.add(consulta);
    // 1. Notifica de forma inmediata a los widgets que están escuchando
    notifyListeners();
  }

  void vaciar() {
    _items.clear();
    notifyListeners();
  }
}

3. Inyección en la Raíz con MultiProvider

Para que los widgets hijos puedan acceder al modelo, envolvemos nuestra aplicación en la raíz utilizando MultiProvider y ChangeNotifierProvider:

import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        // Instancia perezosa (lazy) creada automáticamente la primera vez que se lee
        ChangeNotifierProvider(create: (BuildContext context) => CarritoClinica()),
      ],
      child: const AppClinica(),
    ),
  );
}

4. context.watch frente a context.read

Comprender la diferencia exacta entre estas dos extensiones de BuildContext es vital para evitar bugs y fugas de rendimiento:

Método Suscripción al Rebuild Ubicación Obligatoria Propósito Principal
context.watch<T>() . El widget se reconstruye automáticamente cada vez que se llama a notifyListeners(). Dentro del método build() del widget. Mostrar datos reactivos (textos, listas, totales de precio).
context.read<T>() No. Obtiene la instancia una sola vez sin suscribirse a futuros cambios. Dentro de callbacks de eventos (onPressed, onTap, etc.). Disparar acciones o métodos del modelo sin redibujar el widget que contiene el botón.
Error Crítico: Prohibido context.read dentro del método build

Nunca invoques context.read<T>() dentro de un método build(). Si los datos cambian, el widget no se enterará y mostrará información desactualizada. Por el contrario, nunca uses context.watch<T>() dentro de un onPressed, ya que causará excepciones en tiempo de ejecución.

5. Optimización Quirúrgica con Consumer

Cuando solo un pequeño fragmento de una pantalla compleja requiere actualizarse, el widget Consumer<T> es la solución más limpia. Además, permite usar el parámetro child para inyectar árboles de widgets pesados que nunca deben reconstruirse:

class PantallaCajaClinica extends StatelessWidget {
  const PantallaCajaClinica({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Facturación de Consultas')),
      body: Column(
        children: [
          // 1. Consumer reconstruye ÚNICAMENTE este contenedor cuando cambia el carrito
          Consumer<CarritoClinica>(
            builder: (context, carrito, child) {
              return Card(
                margin: const EdgeInsets.all(16),
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      // Usamos el child estático para el icono y texto fijo
                      child!,
                      Text(
                        'S/ ${carrito.total.toStringAsFixed(2)}',
                        style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.teal),
                      ),
                    ],
                  ),
                ),
              );
            },
            // Este icono y texto se instancian una sola vez y se reutilizan
            child: const Row(
              children: [
                Icon(Icons.point_of_sale, size: 28),
                SizedBox(width: 8),
                Text('Total por Pagar:', style: TextStyle(fontSize: 16)),
              ],
            ),
          ),

          // 2. Botón que añade una consulta usando context.read (sin suscribirse a rebuilds)
          FilledButton.icon(
            icon: const Icon(Icons.add),
            label: const Text('Añadir Consulta Cardiológica (S/ 120.00)'),
            onPressed: () {
              context.read<CarritoClinica>().agregarConsulta(
                const ConsultaMedica(id: 1, doctor: 'Dr. Morales', costo: 120.00),
              );
            },
          ),
        ],
      ),
    );
  }
}

Puntos clave del capítulo

  • Provider elimina el *prop drilling* proporcionando acceso desacoplado a modelos de estado en el árbol.
  • Extiende tus modelos con ChangeNotifier y llama a notifyListeners() tras modificar datos.
  • Declara tus proveedores en la raíz de la aplicación mediante MultiProvider.
  • Usa context.watch<T>() en el build() para suscribir la pantalla a cambios reactivos.
  • Usa context.read<T>() en callbacks como onPressed para ejecutar métodos sin suscripción.
  • Implementa Consumer<T> con la propiedad child para aislar reconstrucciones y proteger componentes estáticos costosos.
Capítulo 16

Ecosistema moderno y asincronía (Future, Stream & Riverpod)

Integra flujos asíncronos y datos en tiempo real de forma reactiva: domina el manejo de estados con FutureBuilder y StreamBuilder erradicando el antipatrón de disparos infinitos, y descubre los fundamentos de Riverpod como la evolución natural para una arquitectura sin dependencias de BuildContext.

  • Consumo asíncrono declarativo: manejar estados de carga, error y datos con FutureBuilder<T>.
  • Erradicar el bucle de peticiones infinitas: preservar la referencia del Future en initState().
  • Procesar flujos continuos: actualización en tiempo real mediante StreamBuilder<T>.
  • Introducción a Riverpod: entender sus ventajas de compilación frente a Provider con ConsumerWidget y WidgetRef.

1. Renderizado Asíncrono con FutureBuilder

En aplicaciones móviles, la obtención de datos de una API REST o base de datos es una operación asíncrona. El widget FutureBuilder<T> se suscribe a un Future y redibuja automáticamente la interfaz según las distintas fases de la conexión capturadas en el objeto AsyncSnapshot:

class VistaMedicosAsincrona extends StatefulWidget {
  const VistaMedicosAsincrona({super.key});

  @override
  State<VistaMedicosAsincrona> createState() => _VistaMedicosAsincronaState();
}

class _VistaMedicosAsincronaState extends State<VistaMedicosAsincrona> {
  // 1. REGLA DE ORO: La variable del Future se inicializa una sola vez en initState
  late final Future<List<String>> _medicosFuture;

  @override
  void initState() {
    super.initState();
    _medicosFuture = _cargarDoctoresDesdeServidor();
  }

  Future<List<String>> _cargarDoctoresDesdeServidor() async {
    // Simulamos un retardo de red de 2 segundos
    await Future.delayed(const Duration(seconds: 2));
    return ['Dr. Roberto Morales (Cardiología)', 'Dra. Elena Ramos (Pediatría)', 'Dr. Carlos Vega (Neurología)'];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Equipo Médico')),
      body: FutureBuilder<List<String>>(
        future: _medicosFuture,
        builder: (BuildContext context, AsyncSnapshot<List<String>> snapshot) {
          // Fase 1: En espera de respuesta (cargando)
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          // Fase 2: Ocurrió un error en la comunicación
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.cloud_off, size: 48, color: Colors.redAccent),
                  const SizedBox(height: 8),
                  Text('Error al conectar: ${snapshot.error}'),
                ],
              ),
            );
          }

          // Fase 3: Datos obtenidos con éxito
          if (snapshot.hasData) {
            final medicos = snapshot.data!;
            return ListView.builder(
              itemCount: medicos.length,
              itemBuilder: (context, i) => ListTile(
                leading: const Icon(Icons.medical_services),
                title: Text(medicos[i]),
              ),
            );
          }

          return const Center(child: Text('No hay datos disponibles'));
        },
      ),
    );
  }
}
Trampa Crítica: Prohibido llamar la función HTTP directo en el build

Nunca escribas FutureBuilder(future: _cargarDoctoresDesdeServidor(), ...) dentro del método build(). Cada vez que el teclado se abra, la pantalla gire o un widget padre se actualice, build() se ejecutará de nuevo, creando una nueva instancia del Future y saturando tu servidor con peticiones HTTP duplicadas e infinitas.

2. Flujos en Tiempo Real con StreamBuilder

A diferencia de un Future (que resuelve un único valor a futuro), un Stream emite múltiples eventos a lo largo del tiempo (ej. mensajes de chat, telemetría cardíaca o WebSockets). El widget StreamBuilder<T> reconstruye la interfaz cada vez que un nuevo dato llega por el flujo:

// Flujo reactivo simulando el pulso cardíaco de un paciente en UCI
Stream<int> monitorCardiacoStream() async* {
  int ritmo = 72;
  while (true) {
    await Future.delayed(const Duration(seconds: 1));
    yield ritmo++; // Emite un nuevo valor por segundo
  }
}

class VistaMonitorUCI extends StatelessWidget {
  const VistaMonitorUCI({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Monitoreo en Tiempo Real')),
      body: Center(
        child: StreamBuilder<int>(
          stream: monitorCardiacoStream(),
          builder: (context, snapshot) {
            if (!snapshot.hasData) {
              return const Text('Calibrando sensores médicos...');
            }
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.favorite, color: Colors.redAccent, size: 64),
                const SizedBox(height: 12),
                Text(
                  '${snapshot.data} BPM',
                  style: const TextStyle(fontSize: 42, fontWeight: FontWeight.bold),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

3. El Futuro del Estado: Introducción a Riverpod

Aunque Provider es robusto, presenta debilidades estructurales: depende estrictamente del BuildContext para resolver dependencias y si intentas leer un proveedor que no existe en el sub-árbol, lanza una excepción en tiempo de ejecución (ProviderNotFoundException).

flutter_riverpod fue creado por el mismo autor de Provider para solucionar esto de raíz: los proveedores se declaran como variables globales inmutables y los errores se detectan en tiempo de compilación:

# Instalación de Riverpod oficial
flutter pub add flutter_riverpod
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. Declaración global inmutable del proveedor (sin depender de BuildContext)
final nombreClinicaProvider = Provider<String>((ref) => 'Clínica San Juan Internacional');

// 2. Envolver la aplicación en ProviderScope en la raíz
void main() {
  runApp(
    const ProviderScope(
      child: AppConRiverpod(),
    ),
  );
}

class AppConRiverpod extends StatelessWidget {
  const AppConRiverpod({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: PantallaRiverpodEjemplo(),
    );
  }
}

// 3. Heredamos de ConsumerWidget en lugar de StatelessWidget
class PantallaRiverpodEjemplo extends ConsumerWidget {
  const PantallaRiverpodEjemplo({super.key});

  // Recibe WidgetRef ref como parámetro adicional en el build
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Escuchamos el proveedor con ref.watch de forma segura
    final nombreClinica = ref.watch(nombreClinicaProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Arquitectura Moderna')),
      body: Center(
        child: Text(
          nombreClinica,
          style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }
}

Puntos clave del capítulo

  • FutureBuilder renderiza interfaces declarativas basadas en los estados del AsyncSnapshot (cargando, error, datos).
  • Inicializa siempre el Future en el método initState() para evitar peticiones duplicadas e infinitas al reconstruir el build().
  • StreamBuilder escucha flujos de datos continuos y actualiza la vista reactivamente en tiempo real.
  • Riverpod desacopla el estado del BuildContext, garantizando seguridad de tipos y detección de errores en tiempo de compilación.
  • En Riverpod, envuelve la app en ProviderScope y usa ConsumerWidget junto a ref.watch() para suscribir tus vistas al estado.
Capítulo 17

Consumo de APIs REST y serialización JSON

Conecta tu aplicación con el mundo exterior de forma robusta y tipada: aprende a emitir peticiones HTTP seguras con el paquete http, decodificar respuestas JSON en modelos Dart inmutables mediante constructores factory fromJson, manejar códigos de estado y capturar fallos de conectividad sin colapsar la app.

  • Configurar el cliente HTTP oficial: peticiones GET, POST, PUT y DELETE con package:http/http.dart.
  • Modelado tipado inmutable: transformar payloads JSON en objetos Dart con fromJson() y toJson().
  • Manejo de códigos de estado HTTP: discriminar entre éxitos 200, no autorizados 401 y errores de servidor 500.
  • Resiliencia de red: capturar SocketException, desconexiones y timeouts de red.

1. El Paquete Oficial http

Flutter no incluye un cliente HTTP de alto nivel en su SDK principal; en su lugar, el equipo oficial mantiene el paquete http, una biblioteca liviana basada en componibilidad de peticiones:

# Instalación de la biblioteca HTTP oficial
flutter pub add http
Permisos de Internet en Android y macOS

En Android, recuerda verificar que android/app/src/main/AndroidManifest.xml contenga la etiqueta <uses-permission android:name="android.permission.INTERNET"/>. En macOS Desktop, debes habilitar el *network client entitlement* en macos/Runner/DebugProfile.entitlements.

2. Modelado de Datos: Patrón fromJson y toJson

En Dart, la mejor práctica consiste en evitar el uso de mapas dinámicos sueltos (Map<String, dynamic>) en la capa de interfaz. Creamos una clase inmutable que convalide los tipos mediante un constructor de fábrica:

import 'dart:convert';

class MedicoModelo {
  final int id;
  final String nombre;
  final String especialidad;
  final String colegiatura;
  final bool activo;

  const MedicoModelo({
    required this.id,
    required this.nombre,
    required this.especialidad,
    required this.colegiatura,
    required this.activo,
  });

  // Constructor de fábrica para instanciar a partir de un Map JSON deserializado
  factory MedicoModelo.fromJson(Map<String, dynamic> json) {
    return MedicoModelo(
      id: json['id'] as int? ?? 0,
      nombre: json['nombre'] as String? ?? 'Sin nombre',
      especialidad: json['especialidad'] as String? ?? 'General',
      colegiatura: json['colegiatura'] as String? ?? 'CMP-00000',
      activo: json['activo'] as bool? ?? true,
    );
  }

  // Método para serializar a JSON al emitir peticiones POST o PUT
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'nombre': nombre,
      'especialidad': especialidad,
      'colegiatura': colegiatura,
      'activo': activo,
    };
  }
}

3. Servicio de Red y Manejo de Respuestas

Encapsulamos la comunicación en una clase de servicio desacoplada, asegurando el tipado y gestionando las excepciones de red con bloques try-catch específicos:

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class MedicosApiService {
  static const String _baseUrl = 'https://api.clinicasanjuan.pe/v1';

  Future<List<MedicoModelo>> obtenerMedicos() async {
    final Uri url = Uri.parse('$_baseUrl/medicos');

    try {
      final response = await http.get(
        url,
        headers: {
          'Accept': 'application/json',
          'Authorization': 'Bearer TOKEN_JWT_CLINICA',
        },
      ).timeout(const Duration(seconds: 10)); // Timeout preventivo

      if (response.statusCode == 200) {
        // Deserializamos el string en una lista de mapas
        final List<dynamic> dataJson = jsonDecode(response.body);
        return dataJson.map((item) => MedicoModelo.fromJson(item)).toList();
      } else if (response.statusCode == 401) {
        throw Exception('Sesión expirada. Por favor inicie sesión nuevamente.');
      } else {
        throw Exception('Error en el servidor: Código ${response.statusCode}');
      }
    } on SocketException {
      // Sin acceso a internet o servidor inaccesible
      throw Exception('No hay conexión a internet. Verifique su red Wi-Fi o datos móviles.');
    } on TimeoutException {
      throw Exception('El servidor tardó demasiado en responder.');
    } catch (e) {
      throw Exception('Ocurrió un error inesperado: $e');
    }
  }

  // Petición POST para registrar una nueva cita
  Future<bool> agendarTurno({required int pacienteId, required int medicoId, required String fecha}) async {
    final Uri url = Uri.parse('$_baseUrl/citas');

    final response = await http.post(
      url,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'paciente_id': pacienteId,
        'medico_id': medicoId,
        'fecha_hora': fecha,
      }),
    );

    return response.statusCode == 201; // 201 Created
  }
}

Puntos clave del capítulo

  • El paquete http proporciona la suite oficial para llamadas REST en Flutter.
  • Nunca consumas mapas JSON crudos en la interfaz gráfica; usa siempre modelos de datos inmutables con fromJson().
  • Configura cabeceras explícitas ('Accept': 'application/json' y 'Content-Type': 'application/json').
  • Aplica siempre .timeout() a tus llamadas de red para impedir que la aplicación quede congelada indefinidamente.
  • Captura de forma diferenciada SocketException para mostrar alertas amigables de desconexión sin conexión Wi-Fi.
Capítulo 18

Persistencia local en el dispositivo

Conserva la configuración, sesiones y estado del usuario entre reinicios del dispositivo móvil: aprende a implementar SharedPreferences de forma asíncrona, encapsular un servicio de preferencias limpio y comprende los límites de seguridad frente al almacenamiento cifrado con Keystore y Keychain.

  • Persistir pares clave-valor: almacenar cadenas, booleanos y números con shared_preferences.
  • Guardar colecciones y objetos: serializar modelos JSON a strings en el disco local.
  • Diseñar un servicio de preferencias: patrón Singleton / Repositorio desacoplado para la capa de UI.
  • Criterio de seguridad móvil: diferencias críticas entre SharedPreferences y flutter_secure_storage.

1. El Paquete Oficial shared_preferences

Para almacenar pequeñas cantidades de datos que deben sobrevivir al cierre de la aplicación (como el tema oscuro, el último documento de identidad consultado o el idioma preferido), Flutter ofrece el paquete oficial shared_preferences, que se conecta con los mecanismos nativos de cada plataforma (XML en Android, NSUserDefaults en iOS y localStorage en Web):

# Instalación del plugin de persistencia local
flutter pub add shared_preferences

2. Operaciones Fundamentales de Lectura y Escritura

El acceso a disco es asíncrono, por lo que primero obtenemos la instancia del almacenamiento antes de operar:

import 'package:shared_preferences/shared_preferences.dart';

Future<void> guardarAjustesUsuario(bool modoOscuro, String sedeClinica) async {
  // 1. Obtenemos la instancia del motor de almacenamiento
  final SharedPreferences prefs = await SharedPreferences.getInstance();

  // 2. Guardamos tipos primitivos
  await prefs.setBool('modo_oscuro_activo', modoOscuro);
  await prefs.setString('sede_preferida', sedeClinica);
  await prefs.setInt('ultimo_acceso_timestamp', DateTime.now().millisecondsSinceEpoch);
}

Future<Map<String, dynamic>> leerAjustesUsuario() async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();

  // Lectura síncrona desde el caché en memoria de prefs
  final bool modoOscuro = prefs.getBool('modo_oscuro_activo') ?? false; // Valor por defecto
  final String sede = prefs.getString('sede_preferida') ?? 'Sede Central San Isidro';

  return {
    'modoOscuro': modoOscuro,
    'sede': sede,
  };
}

3. Encapsulamiento en un Servicio de Preferencias

Para mantener la arquitectura limpia, aislamos la persistencia en un servicio dedicado que la interfaz gráfica pueda consultar sin acoplarse directamente al plugin:

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';

class PreferenciasClinicaService {
  static const String _keyUsuario = 'paciente_sesion';

  // Guardar un objeto estructurado serializándolo a String JSON
  static Future<void> guardarSesionPaciente(Map<String, dynamic> paciente) async {
    final prefs = await SharedPreferences.getInstance();
    final String jsonString = jsonEncode(paciente);
    await prefs.setString(_keyUsuario, jsonString);
  }

  // Recuperar el objeto reconstruyéndolo desde JSON
  static Future<Map<String, dynamic>?> obtenerSesionPaciente() async {
    final prefs = await SharedPreferences.getInstance();
    final String? jsonString = prefs.getString(_keyUsuario);
    if (jsonString == null) return null;
    return jsonDecode(jsonString) as Map<String, dynamic>;
  }

  // Eliminar datos al cerrar sesión
  static Future<void> borrarSesion() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_keyUsuario);
  }
}
Alerta de Seguridad: SharedPreferences NO está cifrado

Los datos guardados con SharedPreferences se almacenan en texto plano en el sistema de archivos del teléfono. Cualquier usuario con un dispositivo rooteado o mediante copias de seguridad de Android puede leer el archivo XML. NUNCA almacenes contraseñas, tokens JWT bancarios o historias clínicas privadas en SharedPreferences. Para credenciales críticas, utiliza flutter_secure_storage, que delega en el Android Keystore y el iOS Keychain con cifrado de hardware AES-256.

Puntos clave del capítulo

  • shared_preferences es la solución oficial para datos ligeros de tipo clave-valor (ajustes, banderas, preferencias).
  • Almacena tipos primitivos: String, int, double, bool y List<String>.
  • Para guardar objetos complejos, serialízalos a cadena de texto con jsonEncode() y reconstrúyelos con jsonDecode().
  • Abstrae siempre el acceso a través de un servicio o repositorio para no dispersar llamadas en tus widgets.
  • No utilices SharedPreferences para información sensible; reserva flutter_secure_storage para tokens y claves criptográficas.
Capítulo 19

Proyecto integrador: App móvil completa

Integra todas las capas arquitectónicas aprendidas en una aplicación móvil real: estructura Material 3 con Scaffold, filtrado interactivo por chips, gestión de estado reactiva con Provider, listas recicladas de alto rendimiento con ListView.builder y diálogos modales para el agendamiento de turnos médicos.

  • Arquitectura integral en capas: separar modelos de dominio, lógica de estado y componentes de presentación.
  • Gestión de estado global: coordinar listados, filtros y cancelaciones con ChangeNotifier y Provider.
  • UI Material 3 profesional: filtros dinámicos con FilterChip, tarjetas limpias y paleta tonal accesible.
  • Interacción completa: diálogos de creación de citas, confirmaciones de borrado y notificaciones flotantes con SnackBar.

1. Estructura y Capas del Proyecto

En este proyecto construiremos la aplicación móvil Clínica Móvil San Juan, que permite al personal médico y a los pacientes consultar las citas del día, filtrarlas por especialidad, registrar nuevas atenciones y cancelar turnos con confirmación en pantalla.

2. Modelo de Dominio y Gestor de Estado (Provider)

Definimos la entidad inmutable Cita y la clase GestorCitas que notifica a la interfaz:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// 1. Modelo inmutable de la cita médica
class Cita {
  final String id;
  final String paciente;
  final String medico;
  final String especialidad;
  final String hora;
  final double costo;

  const Cita({
    required this.id,
    required this.paciente,
    required this.medico,
    required this.especialidad,
    required this.hora,
    required this.costo,
  });
}

// 2. Gestor de estado que administra las citas y el filtro activo
class GestorCitas extends ChangeNotifier {
  final List<Cita> _citas = [
    const Cita(id: '1', paciente: 'Juan Pérez', medico: 'Dr. Roberto Morales', especialidad: 'Cardiología', hora: '09:00 AM', costo: 150.0),
    const Cita(id: '2', paciente: 'María Gómez', medico: 'Dra. Elena Ramos', especialidad: 'Pediatría', hora: '10:30 AM', costo: 120.0),
    const Cita(id: '3', paciente: 'Carlos Silva', medico: 'Dr. Roberto Morales', especialidad: 'Cardiología', hora: '11:45 AM', costo: 150.0),
    const Cita(id: '4', paciente: 'Rosa Mendoza', medico: 'Dr. Carlos Vega', especialidad: 'Neurología', hora: '02:15 PM', costo: 180.0),
  ];

  String _especialidadFiltro = 'Todas';

  String get especialidadFiltro => _especialidadFiltro;

  List<Cita> get citasFiltradas {
    if (_especialidadFiltro == 'Todas') return List.unmodifiable(_citas);
    return _citas.where((c) => c.especialidad == _especialidadFiltro).toList();
  }

  void cambiarFiltro(String nuevaEspecialidad) {
    _especialidadFiltro = nuevaEspecialidad;
    notifyListeners();
  }

  void agregarCita(Cita nuevaCita) {
    _citas.insert(0, nuevaCita);
    notifyListeners();
  }

  void cancelarCita(String id) {
    _citas.removeWhere((c) => c.id == id);
    notifyListeners();
  }
}

3. Interfaz de Usuario Reactiva con Material 3

Implementamos la pantalla principal con barra superior, chips de filtrado horizontal, lista eficiente y botón flotante extendido para registrar nuevas citas:

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => GestorCitas(),
      child: const AppClinicaSanJuan(),
    ),
  );
}

class AppClinicaSanJuan extends StatelessWidget {
  const AppClinicaSanJuan({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Clínica San Juan',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
      home: const PantallaPrincipalClinica(),
    );
  }
}

class PantallaPrincipalClinica extends StatelessWidget {
  const PantallaPrincipalClinica({super.key});

  @override
  Widget build(BuildContext context) {
    // Escuchamos el gestor de citas reactivamente
    final gestor = context.watch<GestorCitas>();
    final especialidades = ['Todas', 'Cardiología', 'Pediatría', 'Neurología'];

    return Scaffold(
      appBar: AppBar(
        scrolledUnderElevation: 3.0,
        title: const Text('Agenda Médica del Día', style: TextStyle(fontWeight: FontWeight.bold)),
        actions: [
          IconButton(
            icon: const Icon(Icons.info_outline),
            onPressed: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text('Total de citas visibles: ${gestor.citasFiltradas.length}'),
                  behavior: SnackBarBehavior.floating,
                ),
              );
            },
          ),
        ],
      ),
      body: Column(
        children: [
          // 1. Selector de filtros con FilterChip horizontal
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Row(
              children: especialidades.map((esp) {
                final bool seleccionada = gestor.especialidadFiltro == esp;
                return Padding(
                  padding: const EdgeInsets.only(right: 8),
                  child: FilterChip(
                    selected: seleccionada,
                    label: Text(esp),
                    onSelected: (_) => gestor.cambiarFiltro(esp),
                  ),
                );
              }).toList(),
            ),
          ),

          // 2. Lista optimizada con ListView.builder
          Expanded(
            child: gestor.citasFiltradas.isEmpty
                ? const Center(child: Text('No hay citas registradas para este filtro.'))
                : ListView.builder(
                    itemCount: gestor.citasFiltradas.length,
                    itemBuilder: (context, index) {
                      final cita = gestor.citasFiltradas[index];
                      return Card(
                        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
                        elevation: 1,
                        child: ListTile(
                          leading: CircleAvatar(
                            backgroundColor: Theme.of(context).colorScheme.primaryContainer,
                            child: const Icon(Icons.person, color: Colors.teal),
                          ),
                          title: Text(cita.paciente, style: const TextStyle(fontWeight: FontWeight.w600)),
                          subtitle: Text('${cita.especialidad} · ${cita.medico}\nHora: ${cita.hora}'),
                          trailing: IconButton(
                            icon: const Icon(Icons.cancel_outlined, color: Colors.redAccent),
                            tooltip: 'Cancelar Cita',
                            onPressed: () async {
                              // Confirmación modal antes de cancelar
                              final bool? confirmar = await showDialog<bool>(
                                context: context,
                                builder: (ctx) => AlertDialog(
                                  title: const Text('¿Cancelar turno?'),
                                  content: Text('¿Desea liberar el turno de las ${cita.hora} de ${cita.paciente}?'),
                                  actions: [
                                    TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('No')),
                                    FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sí, liberar')),
                                  ],
                                ),
                              );

                              if (confirmar == true && context.mounted) {
                                gestor.cancelarCita(cita.id);
                                ScaffoldMessenger.of(context).showSnackBar(
                                  const SnackBar(content: Text('Cita liberada exitosamente.')),
                                );
                              }
                            },
                          ),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton.extended(
        icon: const Icon(Icons.add),
        label: const Text('Nuevo Turno'),
        onPressed: () => _mostrarModalNuevaCita(context),
      ),
    );
  }

  void _mostrarModalNuevaCita(BuildContext context) {
    final pacienteCtrl = TextEditingController();
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Registrar Nuevo Turno'),
        content: TextField(
          controller: pacienteCtrl,
          decoration: const InputDecoration(labelText: 'Nombre completo del paciente'),
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
          FilledButton(
            onPressed: () {
              if (pacienteCtrl.text.trim().isNotEmpty) {
                context.read<GestorCitas>().agregarCita(
                  Cita(
                    id: DateTime.now().millisecondsSinceEpoch.toString(),
                    paciente: pacienteCtrl.text.trim(),
                    medico: 'Dr. Roberto Morales',
                    especialidad: 'Cardiología',
                    hora: '04:00 PM',
                    costo: 150.0,
                  ),
                );
                Navigator.pop(ctx);
              }
            },
            child: const Text('Guardar'),
          ),
        ],
      ),
    );
  }
}

Puntos clave del capítulo

  • La arquitectura limpia separa el modelo de datos (Cita), la lógica de negocio (GestorCitas) y la presentación visual.
  • FilterChip proporciona una experiencia táctil intuitiva y reactiva para el filtrado de datos.
  • ListView.builder asegura que solo se rendericen los ítems visibles en pantalla, garantizando 60/120 fps constantes.
  • Los cuadros de diálogo tipados showDialog<bool> protegen contra eliminaciones accidentales de información.
  • La combinación de context.watch() en la vista y context.read() en los eventos ofrece un flujo de datos unidireccional impecable.
Capítulo 20 · Meta

Testing, profiling, compilación y graduación

Lleva tu aplicación a los estándares más exigentes de la industria: automatiza pruebas unitarias y de widgets con flutter_test, detecta fugas de memoria y caídas de frames con Flutter DevTools, genera paquetes optimizados y ofuscados para Google Play y App Store, y consolida tus conocimientos en el catálogo general.

  • Estrategia de testing automatizado: pruebas unitarias de modelos y pruebas de interfaz con WidgetTester.
  • Diagnóstico con Flutter DevTools: inspección de árboles, métricas de GPU/Raster y control de fugas de memoria.
  • Compilación para producción: generar App Bundles (AAB) para Android y paquetes IPA para iOS con ofuscación de código.
  • Tabla de equivalencias móviles: comparativa entre Flutter, Jetpack Compose, SwiftUI y React Native.

1. La Pirámide de Pruebas en Flutter

Flutter incluye de fábrica una de las suites de testing más completas del desarrollo móvil moderno. Las pruebas se dividen en tres niveles de granularidad:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// 1. Prueba Unitaria: Valida la lógica pura sin renderizar UI
void main() {
  group('Pruebas del Modelo Cita', () {
    test('El cálculo de costo debe ser exacto', () {
      const costoConsulta = 150.0;
      expect(costoConsulta > 0, true);
    });
  });

  // 2. Prueba de Widgets: Simula interacción en un entorno virtual sin emulador
  testWidgets('El botón de nuevo turno debe mostrar el diálogo modal', (WidgetTester tester) async {
    // Montamos un widget envuelto en MaterialApp para simular el entorno real
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Center(
            child: FilledButton(
              onPressed: () {},
              child: const Text('Nuevo Turno'),
            ),
          ),
        ),
      ),
    );

    // Verificamos que el texto esté presente en la pantalla
    expect(find.text('Nuevo Turno'), findsOneWidget);

    // Simulamos un toque táctil en el botón
    await tester.tap(find.byType(FilledButton));

    // pumpAndSettle espera a que todas las micro-animaciones finalicen
    await tester.pumpAndSettle();
  });
}

2. Auditoría y Profiling con Flutter DevTools

Durante el desarrollo, ejecuta flutter run y presiona v en la terminal para abrir Flutter DevTools en el navegador. Esta suite incluye herramientas profesionales indispensables:

Herramienta Diagnóstico Acción Correctiva
Flutter Inspector Explora la jerarquía visual de Widgets y RenderObjects; detecta desbordamientos y márgenes erróneos. Activa «Debug Paint» para visualizar cajas de colisión y márgenes espaciales en vivo.
Performance View Monitorea fotogramas a 60/120 Hz. Identifica barras rojas (*jank*) causadas por bloqueos en el hilo de UI o Raster. Reemplaza widgets dinámicos por constructores const y aísla setState() en nodos pequeños.
Memory Profiler Toma capturas de montículo (*heap snapshots*) para identificar objetos que no se liberan en el Garbage Collector. Comprueba que todos los TextEditingController y AnimationController invoquen su método dispose().
Network Profiler Monitorea cada petición HTTP saliente, cabeceras, tiempos de latencia y tamaños de payload JSON. Optimiza transferencias de red y verifica que las respuestas del backend cuenten con compresión GZIP o Brotli.

3. Compilación y Ofuscación para Producción

Nunca distribuyas una aplicación compilada en modo Debug (que incluye el compilador JIT y herramientas de desarrollo). Para publicar en las tiendas oficiales, utiliza los comandos de producción optimizados AOT:

# 1. Android: Generar Android App Bundle (AAB) para Google Play con ofuscación
flutter build appbundle --release --obfuscate --split-debug-info=build/app/outputs/symbols

# 2. Android: Generar APKs individuales divididos por arquitectura (reduce el peso un 60%)
flutter build apk --split-per-abi --release

# 3. iOS: Generar el paquete de distribución para TestFlight y App Store
flutter build ipa --release

# 4. Web: Compilar aplicación web optimizada para producción
flutter build web --release

4. Tabla Maestra de Equivalencias Móviles

Si provienes de otros entornos de desarrollo o colaboras con equipos multiplataforma, esta matriz te servirá de puente conceptual inmediato:

Concepto / Función Flutter (Dart) Jetpack Compose (Kotlin) SwiftUI (Swift) React Native (TS/JS)
Componente visual base Widget @Composable fun View Component
Estructura de pantalla Scaffold Scaffold NavigationStack SafeAreaView
Flujo vertical Column Column VStack View (flexDirection: column)
Flujo horizontal Row Row HStack View (flexDirection: row)
Lista reciclada virtual ListView.builder LazyColumn List / LazyVStack FlatList
Superposición en capas Stack / Positioned Box ZStack View (position: absolute)
Estado local efímero setState() remember { mutableStateOf() } @State useState()
Inyección y estado global Provider / Riverpod CompositionLocal / ViewModel @EnvironmentObject Context API / Zustand
¡Felicitaciones por completar el manual!

Has dominado Flutter desde sus cimientos arquitectónicos con Impeller, su atlas visual de 22 widgets, maquetación Material 3, interacción, formularios con validaciones, navegación declarativa con GoRouter, gestión de estado con Provider y Riverpod, consumo de APIs REST, persistencia local y testing para tiendas de aplicaciones. ¡Estás listo para crear apps móviles profesionales!

Puntos clave del capítulo

  • Combina pruebas unitarias para modelos con testWidgets para garantizar que los componentes de interfaz respondan como se espera.
  • Usa pumpAndSettle() en tus pruebas para aguardar la culminación de todas las animaciones antes de verificar aserciones.
  • Inspecciona fotogramas en Flutter DevTools para diagnosticar *jank* y asegurar 60 o 120 fotogramas estables.
  • Libera siempre recursos en dispose() para evitar fugas de memoria en el Memory Profiler.
  • Genera paquetes Android App Bundle (AAB) con banderas --obfuscate y --split-debug-info para proteger tu código fuente en producción.