En el desarrollo de aplicaciones con Flutter tienes que juntar la continua variación o cambio de los datos (mutabilidad), con la incertidumbre de cuándo se producen (asincronismo) los mismos, ya antes vimos como usar el gestor de estado Provider en Flutter.
Para aplicaciones pequeñas esta combinación no resulta tan problemático ya que existen múltiples formar en la cual puedes llevar este proceso a cabo; de una manera más manual; por ejemplo si tenemos las siguientes pantallas:

Como puedes ver, es una app muy pequeña, con pocas pantallas relacionadas, en este caso, simplemente dos, una página de listado y otra para modificar estos registros, lógicamente si en el listado de un ítem en particular aparece por ejemplo un título, y quieres modificar el mismo, vas a la siguiente pantalla para editar el mismo y luego de que vuelvas a la pantalla anterior, ese cambio debería verse en el listado; para esto existen múltiples maneras de hacerlo, puedes emplear métodos del propio ciclo de vida de una app en Flutter, puedes disparar la función de promesa del navigator para refrescar los cambios y muchas otras cosas.
Aquí, el factor clave que quiero que entiendas del ejemplo anterior, es que hablamos de incertidumbre, de estos cambios o mutabilidad que pueden ocurrir en cualquier momento.
El problema viene cuando tenemos muchas pantallas relacionadas, por ejemplo en una aplicación de tienda en línea, que aunque la que vamos a llevar a cabo en el curso, por cuestiones de ser prácticos no va tener muchas pantallas, pero que en general este tipo de aplicaciones cuentan con un buen número de pantallas relacionadas; y a que me refiero con pantallas relacionadas:

Por ejemplo la pantalla anterior, que tenemos una app un poco más grande, y algunas pantallas relacionadas, para este ejemplo, tenemos una pantalla de listado de productos, de nuestra pequeña tienda, y múltiples pantallas y opciones para modificar esta data, para mutar la misma, con esto, tenemos que implementar un estado para indicar estos cambios a las pantallas anteriores, ya sea que mediante estas opciones de ejemplo, agreguemos el producto a favorito, le cambiemos el título, agreguemos un comentario y por ende en la pantalla de detalle tenemos que indicar un comentario más o al menos actualizar el total de comentarios, agregamos o removemos el producto del carrito... en pocas palabras un montón de mutabilidad en la data, un montón de cambios que a la final tenemos que reflejar en nuestra aplicación en múltiples pantallas; aquí pudiéramos seguir con la idea inicial, de preguntar el estado del producto cada vez que regresemos a la pantalla anterior, preguntar si, fue agregado a favoritos, lo eliminamos, al carrito y un largo etc, pero como puedes suponer ya esto es un pequeño infierno y si lo hacemos de la manera que te indicaba anteriormente estamos ensuciando la aplicación, nuestras páginas con mucha lógica de negocios que solamente nos sirve para mantener el estado de la aplicación.
Gestión de estado
Para estos casos existen múltiples formas en la cual nosotros podemos mantener ese estado de la aplicación en Flutter.
Ya en el curso, a esta altura vimos uno, el patrón llamado Bloc, que puedes encontrar múltiples implementaciones, pero también tenemos Cubic, providers y por supuesto Redux y muchos más.
Todos estos esquemas para la gestión de estados de nuestra aplicación, resuelven el mismo problema de maneras distintas, como todo en la vida que tenemos variantes; pero aquí lo importante es notar que existen maneras que nosotros podemos colocar a alcance global de la aplicación o en algunas pantallas relacionadas para que cada vez que ocurra un cambio en la data, la data mute a lo largo de toda la app y se actualicen las pantallas; de tal manera que tengamos actualizaciones automáticas en la app y con esto en la práctica, estamos llevando la gestión del estado de la app.
Los principios fundamentales de Redux
Fuente única de datos, store: En Redux hay un único objeto que almacena el estado de toda la aplicación llamada store; en la práctica tenemos un solo archivo en donde manejamos toda la lógica de negocios, que en caso de nuestra app, sería el acceso a la base de datos local, conexión a alguna Api, gestionar los datos del usuario guardados en la preferencias de usuario y un largo etc; de tal manera que con esta clase u objeto global, podemos gestionar distintos proveedores de datos.
Inmutabilidad, el estado es read-only. Ninguna interacción la puede cambiar directamente. Lo único que puedes hacer para conseguir un cambio en la misma, es emitir una acción que expresa su intención de cambiarlo, y esto obviamente es un clic del usuario, un desplazamiento, la navegación a otra pantalla, cualquier cosa o interacción que nosotros registramos por parte del usuario
Funciones puras: Usa netamente funciones para definir cómo cambia el estado en base a una acción. En Redux estas funciones se conocen como reducers y al ser puras, su comportamiento es predecible; por lo tanto, tenemos funciones que reciben únicamente una acción y el estado actual:
(state, action) => newStateDe la aplicación y que simplemente devuelve un nuevo estado (un estado modificado) (ya que no podemos modificar el mismo, porque es inmutable) y es específico a lo cual estamos trabajando; por ejemplo si en el estado tenemos información de usuarios y productos, y el reducers que estamos invocando, se encarga de agregar un producto, entonces el nuevo estado solamente va a devolver lo que compete con los productos y nada del usuario o cualquier otra cosa que tengas en ese estado, y de aquí su nombre de reducers o reductores.
Conceptos claves
Como te comentaba anteriormente, tenemos dos elementos principales, el Store, que es único y global a nuestra aplicación y es el lugar en donde se almacena TODA la data de nuestra aplicación.
Y el State o estado que guarda la información de nuestra aplicación y no se modifica directamente.
El reducer es una función que nos permite crear un nuevo estado en base al viejo estado/state y la acción.
Ciclo básico
- El componente recibe un evento (click, por ejemplo) y emite una acción.
- Esta acción, se pasa a la store, que es donde se guarda el estado único.
- La store comunica la acción junto con el estado actual a los reducers.
- Los reducers, devuelven un nuevo estado, probablemente modificado en base a la acción.
- Los componentes/widgets reciben el nuevo estado de la store.
- La interfaz se actualiza automáticamente
Obviamente a nivel de código todo esto tiene una estructura en particular, pero eso lo veremos en la práctica.
Diagrama básico del Redux:

Diagrama con el Middleware Redux:

Esta variación es la que nosotros vamos a emplear en el curso, y es que necesitamos un Middleware que nos permita realizar ciertos llamados, a nuestra Api; nuestra Api puede ser una base de datos en SQlite, Sembast, nuestro SharePreference y un largo etc; finalmente con la respuesta de la Api, vamos a enviar la respuesta al Reducer que interactúa con nuestro Store, nuestros datos y finalmente devolverá una respuesta a nuestra vista para que refresque los widgets.
Otro punto muy importante es que Redux es una librería que nació para JavaScript y cuya estructura o patrón fue migrado para Flutter; así que si quieres buscar más información, cosa que te recomiendo seguramente vas a encontrar mucha información del mismo aplicado a JavaScript
En este capítulo construiremos una tienda en línea completa con Flutter. El proyecto se desarrolla de manera incremental: comenzamos por las páginas y la navegación, luego agregamos un tema visual global, y finalmente integramos el patrón Redux para gestionar el estado de toda la aplicación de forma centralizada y predecible.
Al momento que se escriben estas palabras, el paquete lleva 3 años que no se actualizan, por lo tanto, podemos concluir lo siguiente:
Tendencia del Ecosistema: La mayoría de los desarrolladores de Flutter que buscan gestión de estado robusta han migrado hacia:
- Riverpod: Actualmente el estándar de facto por su flexibilidad y seguridad en tiempo de compilación.
- Bloc / Cubit: Muy popular por su separación de lógica de negocio y su excelente soporte de herramientas de desarrollo.
- Redux Toolkit (si vienes de JS): Si prefieres mantener la arquitectura Redux, algunos usan implementaciones manuales o combinaciones más modernas, aunque Redux ha perdido mucha tracción en Flutter frente a las opciones mencionadas arriba.
¿Deberías usarlo?
Si es un proyecto nuevo: Te recomendaría mirar Riverpod o Bloc. Tienen comunidades mucho más activas y actualizaciones constantes.
Si es para mantenimiento: No es "peligroso" usarlo, pero ten en cuenta que no aprovecha las mejoras más recientes del framework de los últimos años.
Por lo tanto, te dejo este capítulo con Redux para que conozcas el esquema y se recomienda al lector NO emplear Redux en nuevos desarrollos.
1. Creación del Proyecto y Estructura de Páginas
Todo proyecto Flutter parte de una estructura clara. Para una tienda en línea necesitamos al menos tres pantallas fundamentales: la de productos, la de inicio de sesión y la de registro. En Flutter, cada pantalla es un widget que reside en su propio archivo dentro de la carpeta lib/pages/.
La carpeta de productos se organiza como una subcarpeta dedica, separando el catálogo general del detalle de cada artículo:
lib/
pages/
login_page.dart
register_page.dart
product/
products_page.dart
detail_page.dart
cart/
index_page.dart
models/
redux/
widgets/Cada página declara una constante estática ROUTE que identifica su ruta de navegación. Esto establece un contrato claro entre las páginas y el sistema de rutas, evitando cadenas de texto duplicadas en el código.
2. El Punto de Entrada: main.dart y el Sistema de Rutas
El archivo main.dart es el corazón de la aplicación. Es donde se declara el widget raíz MyApp, se configura el tema global y se define el mapa de rutas nombradas. En Flutter, las rutas nombradas permiten navegar entre páginas usando cadenas de texto en lugar de instanciar widgets directamente.
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tienda en Línea',
initialRoute: ProductsPage.ROUTE,
routes: {
ProductsPage.ROUTE: (_) => ProductsPage(),
LoginPage.ROUTE: (_) => LoginPage(),
RegisterPage.ROUTE: (_) => RegisterPage(),
DetailPage.ROUTE: (_) => DetailPage(),
},
);
}
}La propiedad initialRoute determina qué página se muestra al arrancar la aplicación. En este caso, la pantalla principal de productos es la primera en aparecer.
3. Tema Global: Modo Oscuro y Paleta de Colores
Una de las grandes ventajas de Flutter es la centralización del estilo mediante ThemeData. Al definir colores y tipografías una sola vez en main.dart, cualquier cambio de diseño se refleja automáticamente en toda la aplicación.
Para esta tienda elegimos un modo oscuro con una paleta basada en morado profundo como color primario y naranja como color de acento, una combinación contrastante y moderna.
theme: ThemeData(
colorScheme: ColorScheme.fromSwatch(
primarySwatch: Colors.deepPurple,
brightness: Brightness.dark,
).copyWith(
secondary: const Color(0xFFFF5722),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontSize: 35.0, fontWeight: FontWeight.bold),
displayMedium: TextStyle(fontSize: 21.0, fontWeight: FontWeight.bold),
displaySmall: TextStyle(fontSize: 17.0),
titleLarge: TextStyle(fontSize: 15.0),
bodyLarge: TextStyle(fontSize: 14.0),
bodyMedium: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
),
useMaterial3: true),El sistema tipográfico establece una jerarquía visual equivalente a los encabezados HTML. displayLarge actúa como un H1 de gran tamaño para el título de cada página, mientras que bodyLarge cubre el texto de contenido general.
4. Página de Login: Formularios con Validación
La página de inicio de sesión es un StatefulWidget porque necesita mantener estado local: si el formulario está enviándose, si la contraseña está oculta, y los valores de los campos de texto. Toda esa información cambia con el tiempo y afecta la interfaz.
4.1 Estructura del Widget
El Scaffold envuelve el formulario, que a su vez usa un GlobalKey<FormState> para poder validar todos los campos de manera programática desde el botón de envío.
class LoginPage extends StatefulWidget {
static const String ROUTE = "/login";
@override
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
bool isSubmitted = false;
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Login")),
body: Form(
key: _formKey,
child: Container(
margin: EdgeInsets.all(8),
child: Column(children: [
_title(),
SizedBox(height: 15),
_emailTF(),
_passwordTF(),
_actions(),
]),
),
),
);
}
}4.2 Campos de Texto como Métodos Privados
Cuando un widget crece en complejidad, el árbol de widgets dentro de build se vuelve difícil de leer. Una práctica recomendada es extraer partes del árbol a métodos privados que retornen un Widget. Esto mejora la legibilidad sin el overhead de crear nuevas clases.
Widget _emailTF() {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: TextFormField(
controller: _emailController,
validator: (val) => val!.length < 3 ? 'Cuenta inválida' : null,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Email o usuario',
hintText: 'Coloque un email o usuario',
icon: Icon(
Icons.email,
color: Theme.of(context).colorScheme.secondary,
)),
),
);
}El parámetro validator recibe una función que devuelve null cuando la validación es exitosa, o una cadena de texto con el mensaje de error cuando falla. Flutter muestra ese texto debajo del campo automáticamente.
4.3 Toggle de Contraseña
Mostrar u ocultar la contraseña es un patrón muy común. Se implementa con una variable booleana de estado y un GestureDetector que la invierte al recibir un toque.
Widget _passwordTF() {
return TextFormField(
obscureText: _obscurePassword,
controller: _passwordController,
validator: (val) => val!.length < 5 ? 'Contraseña inválida' : null,
decoration: InputDecoration(
suffixIcon: GestureDetector(
onTap: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
child: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off)),
border: OutlineInputBorder(),
labelText: 'Password',
icon: Icon(Icons.lock,
color: Theme.of(context).colorScheme.secondary)),
);
}La propiedad obscureText de TextFormField controla si el texto se muestra o se oculta. Al llamar setState, Flutter redibuja el widget con el nuevo valor del booleano, cambiando tanto el ícono como la visibilidad del texto.
4.4 Validación del Formulario al Enviar
El botón de envío usa la clave del formulario para invocar validate(), que recorre todos los campos y ejecuta sus funciones validator. Si todas retornan null, el método devuelve true y se procede.
Widget _actions() {
return Column(
children: [
isSubmitted
? CircularProgressIndicator()
: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
),
child: Text("Enviar",
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(color: Colors.white)),
onPressed: () {
if (_formKey.currentState!.validate()) {
print("Formulario válido!");
_loginUser();
} else {
print("errores en el form");
}
}),
TextButton(
onPressed: () {
Navigator.pushReplacementNamed(context, RegisterPage.ROUTE);
},
child: Text("¿Tienes una cuenta?"))
],
);
}El CircularProgressIndicator reemplaza al botón mientras isSubmitted es verdadero, dando retroalimentación visual al usuario durante la petición HTTP sin necesidad de un modal separado.
5. Página de Registro: Extender el Formulario
La página de registro es estructuralmente idéntica a la de login, con la diferencia de que agrega un campo adicional para el nombre de usuario. Esto ilustra cómo reutilizar patrones establecidos.
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
bool isSubmitted = false;
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Widget _usernameTF() {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: TextFormField(
controller: _usernameController,
validator: (val) => val!.length < 3 ? 'Usuario inválido' : null,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Usuario',
hintText: 'Coloque un usuario',
icon: Icon(Icons.person,
color: Theme.of(context).colorScheme.secondary)),
),
);
}
}El título de cada página se construye también como un método privado que consume el tema global, garantizando consistencia tipográfica en toda la aplicación:
Widget _title() {
return Text(
'Registrar',
style: Theme.of(context).textTheme.displayLarge,
);
}App Móvil E-Commerce: MongoDB, Strapi (Node.js) y Flutter
Hasta este punto la aplicación cuenta con una interfaz completa y un sistema de estado con Redux. El siguiente paso es el más significativo: abandonar los datos estáticos y conectar la app a un backend real. En este capítulo configuraremos toda la infraestructura de servidor, implementaremos el registro de usuarios y construiremos el flujo completo de autenticación, incluyendo feedback visual, persistencia de sesión y redirección automática.
1. ¿Por Qué No Conectar Flutter Directamente a MongoDB?
Al aprender sobre bases de datos es natural preguntarse: ¿Por qué no conectar Flutter directamente a MongoDB y ya? La respuesta tiene que ver con seguridad fundamental.
Para conectarte a MongoDB necesitas credenciales: un usuario y una contraseña de base de datos. Si las incluyeras en el código de la app Flutter, cualquier persona que descargue el APK podría extraerlas con herramientas de ingeniería inversa y tendría acceso ilimitado a toda tu base de datos. La consecuencia sería catastrófica.
La solución que adoptamos es una arquitectura de tres capas:
- MongoDB Atlas (Datos): La base de datos vive en la nube y solo acepta conexiones desde la IP del servidor backend, no desde los dispositivos móviles.
- Strapi sobre Node.js (API): Es el intermediario seguro. Se conecta a MongoDB con las credenciales protegidas en el servidor y expone a Flutter únicamente los recursos y operaciones que nosotros definamos explícitamente, mediante una API REST con autenticación JWT.
- Flutter (Cliente): Nunca toca la base de datos directamente. Solo habla con Strapi mediante peticiones HTTP estándar, enviando y recibiendo JSON.
La elección de Strapi es estratégica: genera automáticamente rutas de autenticación, gestión de usuarios y un panel de administración sin escribir una línea de código de backend, permitiéndonos enfocarnos en Flutter.
2. Configurar MongoDB Atlas
MongoDB Atlas es el servicio oficial en la nube de MongoDB con un nivel gratuito (tier M0 Free) suficiente para desarrollo y proyectos de pequeña escala. La gran ventaja es que no necesitas instalar nada localmente.
El proceso tiene cuatro pasos. Es importante no saltarse ninguno; especialmente el de la IP Whitelist, cuyo error resultante no da ninguna pista descriptiva de la causa.
- Crear cuenta, organización y proyecto en cloud.mongodb.com. La estructura es: Organización → Proyecto → Cluster.
- Crear un Cluster gratuito. Selecciona cualquier proveedor (AWS, GCP o Azure) y asegúrate de que diga "Free" o "$0/month". El proceso puede tardar entre 3 y 10 minutos.
- Crear un usuario de base de datos en "Database Access". Dale permisos de lectura y escritura. Guarda el usuario y la contraseña porque los necesitarás al instalar Strapi.
- Agregar tu IP a la Whitelist en "Network Access". Sin este paso, MongoDB rechaza todas las conexiones entrantes silenciosamente. Para desarrollo puedes usar "Add Current IP Address" o permitir
0.0.0.0/0temporalmente. Si cambias de red, deberás agregar la nueva IP.
Finalmente ve a "Connect" → "Connect your application" y copia el Connection String:
mongodb+srv://mongo:<contraseña>@cluster0.xxxxx.mongodb.net/market?retryWrites=true&w=majority3. Instalar Strapi y Conectarlo a MongoDB
Con Node.js instalado en tu equipo, usa npx para crear el proyecto Strapi. Elige la opción Custom en el asistente (no --quickstart, que crea la base de datos con SQLite local):
npx create-strapi-app marketEn el asistente configura: Database = MongoDB, Database name = market, y pega el Connection String de Atlas. Si la conexión es exitosa, Strapi lo confirma en la consola. Luego levanta el servidor:
cd market
npm run developStrapi arranca en el puerto 1337. Abre http://localhost:1337/admin y crea tu usuario administrador. Este usuario es exclusivo del panel de control y es independiente de los usuarios de la aplicación Flutter.
Desde el panel de Strapi hay dos secciones clave para este proyecto:
- Content-Types Builder: Modelador de datos. Cada colección que crees (Productos, Pedidos, etc.) se convierte automáticamente en una ruta REST de tu API.
- Roles & Permissions: Por defecto, la API está completamente bloqueada. Deberás habilitar explícitamente los endpoints que quieras que sean públicos o autenticados.
Las rutas de autenticación que usaremos en Flutter ya existen por defecto en Strapi: POST /auth/local/register para registrar un usuario y POST /auth/local para iniciar sesión. Ambas responden con el token JWT y los datos del usuario.
4. Instalar el Paquete HTTP en Flutter
Con el backend listo, pasamos al lado Flutter. Agrega el paquete http y shared_preferences en pubspec.yaml:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
shared_preferences: ^2.2.0Ejecuta flutter pub get y añade las importaciones en la página de registro:
import 'package:http/http.dart' as http;
import 'dart:convert';El alias as http es una convención importante: hace que cada llamada sea explícita (http.post()) y evita colisiones de nombres con otras funciones del proyecto.
5. Registrar un Usuario desde Flutter
Strapi expone la ruta POST /auth/local/register para crear nuevos usuarios. Hay un detalle técnico crítico antes de escribir la URL: si usas el emulador de Android, no puedes usar localhost. El emulador es una máquina virtual con su propia red; cuando escribe localhost busca un servidor dentro del propio teléfono virtual, no en tu computadora. La dirección especial que el emulador de Android usa para referirse al equipo anfitrión es 10.0.2.2.
void _registerUser() async {
// 1. Bloquear la UI para evitar envíos duplicados
setState(() => isSubmitted = true);
// 2. Petición POST al endpoint de registro de Strapi
final res = await http.post(
Uri.parse('http://10.0.2.2:1337/auth/local/register'),
body: {
'username': _usernameController.text,
'email': _emailController.text,
'password': _passwordController.text,
},
);
// 3. Restaurar el botón cuando llega la respuesta
setState(() => isSubmitted = false);
// 4. Convertir el body de String JSON a Map de Dart
final responseData = json.decode(res.body) as Map<String, dynamic>;
// 5. Evaluar el resultado
if (res.statusCode == 200) {
_successResponse();
_storeUserData(responseData);
_redirectUser();
} else {
// Strapi devuelve los errores con esta estructura anidada:
// { "message": [{ "messages": [{ "message": "Email already taken" }] }] }
final errorMessage = responseData['message'][0]['messages'][0]['message'];
_errorResponse(errorMessage);
}
}La función usa async/await: await pausa esta función hasta recibir la respuesta HTTP, sin bloquear el hilo principal de la UI. El resto de la aplicación sigue funcionando con normalidad.
6. Mejorando la Experiencia del Usuario (UX)
Una aplicación profesional no solo debe funcionar, sino que debe comunicar claramente al usuario lo que está ocurriendo. Vamos a implementar tres mejoras vitales en nuestra experiencia de usuario.
A. Bloqueo del Botón de Envío
Si un usuario toca el botón de "Enviar" repetidas veces porque su internet es lento, nuestra app enviará múltiples peticiones al servidor, lo cual podría causar errores. Para prevenirlo, reemplazamos el botón por un indicador de progreso mientras procesamos los datos, usando la variable isSubmitted.
// En el método build de nuestra página:
isSubmitted
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_registerUser();
}
},
child: const Text("Enviar"),
)B. Feedback Visual con SnackBars
Cuando el registro falla (por ejemplo, el correo ya está en uso) o cuando tiene éxito, debemos decírselo al usuario de forma elegante. Los SnackBars de Material Design son perfectos para esto. Usamos ScaffoldMessenger para invocarlos fácilmente desde cualquier parte.
void _successResponse() {
final snackBar = SnackBar(
content: Text(
'¡Felicidades, ${_usernameController.text}! Usuario creado con éxito.',
style: TextStyle(color: Theme.of(context).colorScheme.secondary),
),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void _errorResponse(String msj) {
final snackBar = SnackBar(
backgroundColor: Colors.red,
content: Text(
msj,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}C. Redirección Programática con Retardo
Cuando el registro es exitoso, queremos llevar al usuario a la página de productos. Sin embargo, si hacemos la navegación inmediatamente, el usuario no tendrá tiempo de leer nuestro mensaje de éxito. Utilizamos Future.delayed para crear una pausa de 2 segundos.
Además, usamos Navigator.pushReplacementNamed para destruir la página de registro del historial. De esta manera, si el usuario presiona el botón de retroceso en su celular, no volverá a la pantalla de registro de forma accidental.
void _redirectUser() {
Future.delayed(const Duration(seconds: 2), () {
Navigator.pushReplacementNamed(context, ProductsPage.ROUTE);
});
}7. Persistencia de Sesión con SharedPreferences
Cuando Strapi responde con un código 200, devuelve un JSON con el token JWT y los datos del usuario. El JWT es la credencial que deberás enviar en las cabeceras de todas las peticiones futuras que requieran autenticación.
Si no guardamos este token de forma persistente, el usuario tendrá que iniciar sesión cada vez que cierre y abra la app. SharedPreferences es la solución idiomática de Flutter: almacena pares clave-valor en el almacenamiento del dispositivo y los conserva entre sesiones.
void _storeUserData(Map<String, dynamic> responseData) async {
final prefs = await SharedPreferences.getInstance();
prefs.setString('jwt', responseData['jwt']);
prefs.setString('id', responseData['user']['_id']);
prefs.setString('username', responseData['user']['username']);
prefs.setString('email', responseData['user']['email']);
}Al arrancar la app puedes leer el token con prefs.getString('jwt'). Si no es null, el usuario ya tiene sesión activa y puedes enviarlo directamente a la tienda. Esto se integra con la ThunkAction de Redux getUserAction que inicializa el Store con los datos del usuario al inicio.
Consejo importante: Cuando instales shared_preferences por primera vez, haz un reinicio completo de la app (detener y volver a correr, no solo Hot Reload). Los plugins nativos requieren que la aplicación se recompile para que su código nativo quede correctamente vinculado.
8. Replicar el Flujo en la Página de Login
El proceso de inicio de sesión es estructuralmente idéntico al de registro. El patrón se repite: bloquear botón → petición HTTP → procesar respuesta → guardar datos → redirigir. Solo cambian dos cosas:
- La URL: ahora es
POST /auth/local(sin "/register"). - El campo de identificación: Strapi usa
identifier, que acepta tanto el email como el username. El usuario puede iniciar sesión con cualquiera.
void _loginUser() async {
setState(() => isSubmitted = true);
final res = await http.post(
Uri.parse('http://10.0.2.2:1337/auth/local'),
body: {
'identifier': _emailController.text, // email o username
'password': _passwordController.text,
},
);
setState(() => isSubmitted = false);
final responseData = json.decode(res.body) as Map<String, dynamic>;
if (res.statusCode == 200) {
_successResponse();
_storeUserData(responseData);
_redirectUser();
} else {
final errorMessage = responseData['message'][0]['messages'][0]['message'];
_errorResponse(errorMessage);
}
}La respuesta exitosa del login tiene exactamente la misma estructura que la del registro: { "jwt": "...", "user": { ... } }. Por eso podemos reutilizar sin cambios las funciones _storeUserData y _redirectUser en ambas páginas.
Conclusión
¡Felicidades! Hemos dado un gran salto técnico. Has aprendido a desacoplar la lógica de tu aplicación utilizando una arquitectura limpia con Flutter, Strapi y MongoDB. Ahora tu aplicación no es solo un cascarón vacío; puede conectarse de forma segura a la nube, registrar usuarios reales, manejar sus credenciales con encriptación (cortesía de Strapi), persistir la sesión localmente y comunicarse con el usuario a través de notificaciones fluidas.
Estos patrones asíncronos y el manejo de APIs que has aprendido aquí son el pan de cada día en el desarrollo móvil profesional. No importa si mañana decides cambiar Strapi por Firebase, Laravel o Django; los conceptos de peticiones HTTP, parseo de JSON, persistencia local y manejo de estados de carga en Flutter siempre serán exactamente los mismos.
Código fuente completo: https://github.com/libredesarrollo/flutter2-market-09/
Ampliando la App con Redux y Strapi: Productos, Carrito y Favoritos
Con nuestro backend funcionando y la autenticación integrada, necesitamos un sistema robusto para gestionar el estado compartido entre pantallas: el catálogo de productos, el carrito y los favoritos. Un estado manual disperso en cada widget se vuelve rápidamente inmanejable. El patrón Redux resuelve esto centralizando toda la información en un único objeto global e inmutable.
1. Por Qué Redux: El Problema del Estado Compartido
Imagina que el usuario agrega un producto al carrito desde la página de detalle. Ese cambio debe reflejarse en: el ícono del carrito del AppBar, la lista de la página de productos (que muestra si ya está en el carrito), y la propia página del carrito. Coordinar eso sin una herramienta centralizada implica pasar callbacks entre múltiples niveles de widgets (prop drilling) o recargar datos del servidor en cada pantalla.
Redux resuelve esto con un flujo unidireccional claro:
- Store: El único objeto global que contiene todo el estado de la app.
- AppState: Clase inmutable que define la forma del estado: usuario, productos, carrito, favoritos.
- Actions: Clases o funciones que expresan la intención de un cambio ("agregar al carrito", "marcar favorito").
- Reducers: Funciones puras que reciben el estado actual y una acción, y devuelven un estado nuevo.
- Thunk Actions: Funciones asíncronas que pueden llamar a APIs externas antes de despachar la acción al reducer.
Instala las dependencias en pubspec.yaml:
dependencies:
flutter_redux: ^0.10.0
redux_thunk: ^0.4.02. Estructura de Archivos del Patrón Redux
Organizamos todo el código relacionado con Redux en una carpeta redux/ dentro de lib/:
lib/
redux/
reducers.dart <-- Reducer global que delega a reductores específicos
actions.dart <-- Acciones y Thunk Actions
models/
app_state.dart <-- La clase AppState inmutable
user.dart
product.dartMantener el AppState en models/ tiene sentido porque controla toda la aplicación y no es exclusivo del módulo Redux.
3. Crear el AppState
El AppState es una clase anotada con @immutable, lo que le indica al analizador de Dart que todas sus propiedades deben ser final y que nunca debe modificarse directamente. Para cambiarla, se crea una copia nueva:
import 'package:meta/meta.dart';
@immutable
class AppState {
final dynamic user;
final List<dynamic> products;
const AppState({required this.user, required this.products});
// Constructor nombrado que devuelve el estado inicial de la aplicación
factory AppState.initial() {
return AppState(user: null, products: const []);
}
}El constructor factory AppState.initial() es obligatorio en Redux. Es el estado con el que arranca la app antes de cargar cualquier dato de la red o del dispositivo.
4. Crear los Reducers
Creamos un reducer global (appReducer) que delega cada parte del estado a un reducer específico. Cada reducer solo manipula su porción del estado y retorna el resto sin tocarlo:
// redux/reducers.dart
import '../models/app_state.dart';
import 'actions.dart';
AppState appReducer(AppState state, dynamic action) {
return AppState(
user: userReducer(state.user, action),
products: productsReducer(state.products, action),
);
}
dynamic userReducer(dynamic state, dynamic action) {
if (action is GetUserAction) {
return action.user;
}
return state;
}
List<dynamic> productsReducer(List<dynamic> state, dynamic action) {
if (action is GetProductsAction) {
return action.products;
}
return state;
}La clave está en que userReducer nunca toca la lista de productos, y productsReducer nunca toca el usuario. Cada función es "pura": sin efectos secundarios, sin llamadas a red, sin mutaciones.
5. Crear las Acciones (Actions y Thunk Actions)
Las Actions son clases simples que transportan datos al reducer. Las Thunk Actions son funciones que primero llaman a una API y luego despachan la acción simple al reducer:
// redux/actions.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:redux/redux.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_state.dart';
// --- Acción simple (tipado de datos) ---
class GetUserAction {
final dynamic user;
GetUserAction(this.user);
}
// --- Thunk Action (asíncrona) ---
ThunkAction<AppState> getUserAction = (Store<AppState> store) async {
final prefs = await SharedPreferences.getInstance();
final email = prefs.getString('email');
if (email == null) {
store.dispatch(GetUserAction(null));
return;
}
// Construye un Map con los datos del usuario guardados localmente
final user = {
'email': email,
'username': prefs.getString('username'),
'id': prefs.getString('id'),
'jwt': prefs.getString('jwt'),
};
store.dispatch(GetUserAction(user));
};El patrón se lee así: cuando se llama a getUserAction, el middleware Thunk ejecuta la función. Esta función accede a SharedPreferences, construye el mapa del usuario y despacha GetUserAction con esos datos. El reducer entonces actualiza el AppState con el nuevo usuario.
6. Conectar Redux al Árbol de Widgets
En main.dart creamos el Store y lo inyectamos en toda la app mediante StoreProvider:
void main() {
final store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [thunkMiddleware],
);
runApp(MyApp(store: store));
}
class MyApp extends StatelessWidget {
final Store<AppState> store;
const MyApp({required this.store});
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(
// rutas...
),
);
}
}7. Consumir el Store con StoreConnector
StoreConnector es el widget que conecta cualquier parte del árbol de widgets al Store. Se parametriza con dos tipos: el estado global y el "view model" (el dato concreto que le interesa a este widget).
Para mostrar el nombre del usuario en el AppBar y despachar la carga de datos al entrar a la pantalla de productos, usamos el callback onInit:
// En products_page.dart
StoreConnector<AppState, AppState>(
converter: (store) => store.state,
onInit: (store) {
store.dispatch(getUserAction);
store.dispatch(getProductsAction);
},
builder: (context, state) {
final user = state.user;
return Scaffold(
appBar: AppBar(
title: Text(user != null ? 'Hola, ${user['username']}' : 'Tienda'),
actions: [
PopupMenuButton(
itemBuilder: (_) => [
if (user != null)
PopupMenuItem(value: 'logout', child: Text('Cerrar sesión'))
else
PopupMenuItem(value: 'login', child: Text('Iniciar sesión')),
],
onSelected: (value) {
if (value == 'logout') store.dispatch(logoutAction);
if (value == 'login') Navigator.pushNamed(context, LoginPage.ROUTE);
},
)
],
),
body: /* GridView de productos */,
);
},
)8. Catálogo de Productos: Strapi y Redux
8.1 Configurar el Content Type en Strapi
Antes de mostrar productos en Flutter, deben existir en el backend. En el panel de Strapi usamos el Content-Types Builder para crear una colección products con los campos: nombre (Text), descripcion (Rich Text), precio (Number decimal) e imagen (Media). Creamos varios productos de prueba desde el panel de Content Manager.
Después de poblar la colección, ve a Settings → Roles & Permissions → Public y habilita los métodos find y findOne para products. Sin este paso, Strapi responde con 403 aunque el endpoint exista.
8.2 Thunk Action para Obtener Productos
class GetProductsAction {
final List<dynamic> products;
GetProductsAction(this.products);
}
ThunkAction<AppState> getProductsAction = (Store<AppState> store) async {
final res = await http.get(Uri.parse('http://10.0.2.2:1337/products'));
final data = json.decode(res.body) as List<dynamic>;
store.dispatch(GetProductsAction(data));
};Esta acción se despacha en el onInit del StoreConnector de la página de productos, junto con getUserAction. Ambas corren en paralelo al entrar a la pantalla.
9. El Modelo Product
Para trabajar con tipado fuerte creamos la clase Product. Además de los campos del servidor, incluye propiedades de estado local (favorite y cartCount) que Redux necesita para reflejar el estado de la UI sin afectar los datos persistidos:
class Product {
final String id;
final String nombre;
final String descripcion;
final double precio;
final String imagen;
bool favorite;
int cartCount;
Product({
required this.id,
required this.nombre,
required this.descripcion,
required this.precio,
required this.imagen,
this.favorite = false,
this.cartCount = 0,
});
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['_id'].toString(),
nombre: map['nombre'] ?? '',
descripcion: map['descripcion'] ?? '',
precio: (map['precio'] as num).toDouble(),
imagen: map['imagen'] != null
? 'http://10.0.2.2:1337\${map['imagen']['url']}'
: '',
);
}
}La URL de la imagen necesita el prefijo del servidor porque Strapi devuelve rutas relativas como /uploads/producto.jpg. El método fromMap agrega ese prefijo automáticamente.
10. GridView Responsivo del Catálogo
Con los productos tipados en el estado global los mostramos con un GridView.builder. El número de columnas varía según orientación y ancho de pantalla:
builder: (context, state) {
final orientation = MediaQuery.of(context).orientation;
final width = MediaQuery.of(context).size.width;
int columns = 2;
if (orientation == Orientation.landscape) columns = 3;
if (width > 800) columns = 4;
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
),
itemCount: state.products.length,
itemBuilder: (context, i) {
final p = state.products[i];
return GestureDetector(
onTap: () => Navigator.pushNamed(
context, DetailPage.ROUTE, arguments: p,
),
child: Card(
child: Column(children: [
Expanded(child: Image.network(p.imagen, fit: BoxFit.cover)),
Text(p.nombre),
Text('\$\${p.precio.toStringAsFixed(2)}'),
]),
),
);
},
);
}11. Página de Detalle del Producto
Al hacer tap navegamos con Navigator.pushNamed pasando el objeto Product como argumento de ruta. La página de detalle lo recupera con:
final Product product =
ModalRoute.of(context)!.settings.arguments as Product;Esta pantalla muestra la imagen a gran escala, descripción, precio y los botones de Favoritos y Carrito. Ambos botones se envuelven en un StoreConnector para reflejar el estado actual del producto en tiempo real y despachar acciones directamente al Store.
12. Funcionalidad de Favoritos (Toggle)
El botón de favoritos alterna un ícono de corazón. Despachamos una acción que invierte el booleano favorite del producto correspondiente en la lista del estado:
class ToggleFavoriteAction {
final Product product;
ToggleFavoriteAction(this.product);
}
// En el reducer de productos:
if (action is ToggleFavoriteAction) {
return state.map((p) {
if (p.id == action.product.id) p.favorite = !p.favorite;
return p;
}).toList();
}En la UI el ícono se actualiza instantáneamente porque StoreConnector reconstruye el widget al detectar cualquier cambio en el Store:
IconButton(
icon: Icon(
product.favorite ? Icons.favorite : Icons.favorite_border,
color: product.favorite ? Colors.red : null,
),
onPressed: () => store.dispatch(ToggleFavoriteAction(product)),
)13. Carrito: Página, CartItem y Dismissible
13.1 Página del Carrito y Widget CartItem
La página del carrito filtra el estado mostrando solo los productos con cartCount > 0. Creamos un widget CartItem que encapsula: imagen, nombre, precio unitario, selectores de cantidad (+/−) y total parcial de ese producto.
13.2 Eliminar con Dismissible y Confirmación
Envolvemos cada CartItem en Dismissible. El callback confirmDismiss muestra un AlertDialog antes de procesar el gesto, evitando eliminaciones accidentales:
Dismissible(
key: Key(product.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('¿Eliminar producto?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Eliminar'),
),
],
),
);
},
onDismissed: (_) => store.dispatch(RemoveFromCartAction(product)),
child: CartItem(product: product),
)La key debe ser el ID real del producto, no el índice de la lista. Con el índice, Flutter puede aplicar el fondo rojo de borrado al elemento incorrecto después de reconstruir la lista.
13.3 Selectores de Cantidad
class IncrementCartAction { final Product product; IncrementCartAction(this.product); }
class DecrementCartAction { final Product product; DecrementCartAction(this.product); }
// En el reducer:
if (action is IncrementCartAction) {
return state.map((p) {
if (p.id == action.product.id) p.cartCount++;
return p;
}).toList();
}
if (action is DecrementCartAction) {
return state.map((p) {
if (p.id == action.product.id && p.cartCount > 0) p.cartCount--;
return p;
}).toList();
}14. Esquema del Carrito en Strapi y Creación al Registrarse
Para persistir el carrito entre sesiones creamos en Strapi un tipo Cart con relación one-to-one a User, relación many-to-many a Product, y un campo quantities (Text/JSON) para las cantidades. Al registrar un usuario, creamos su carrito vacío:
await http.post(
Uri.parse('http://10.0.2.2:1337/carts'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \$jwt',
},
body: json.encode({
'user': userId, 'products': [], 'quantities': '{}',
}),
);15. Sincronizar el Carrito con Strapi (Thunk Action)
Cada modificación del carrito actualiza primero el estado local (feedback inmediato) y luego persiste los datos en Strapi con HTTP PUT:
ThunkAction<AppState> saveCartAction(Product product) {
return (Store<AppState> store) async {
store.dispatch(ToggleCartAction(product));
final state = store.state;
final cartItems = state.products.where((p) => p.cartCount > 0).toList();
final quantities = {for (var p in cartItems) p.id: p.cartCount};
await http.put(
Uri.parse('http://10.0.2.2:1337/carts/\${state.user['cartId']}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \${state.user['jwt']}',
},
body: json.encode({
'products': cartItems.map((p) => p.id).toList(),
'quantities': json.encode(quantities),
}),
);
};
}16. Cargar el Carrito al Iniciar la App
Al arrancar, después de cargar usuario y productos, inicializamos el carrito desde Strapi y actualizamos los cartCount de cada producto en el estado local:
ThunkAction<AppState> initCartAction = (Store<AppState> store) async {
final user = store.state.user;
if (user == null) return;
final res = await http.get(
Uri.parse('http://10.0.2.2:1337/carts?user=\${user['id']}'),
headers: {'Authorization': 'Bearer \${user['jwt']}'},
);
final data = json.decode(res.body) as List;
if (data.isEmpty) return;
final cart = data[0];
final quantities = json.decode(cart['quantities'] ?? '{}') as Map<String, dynamic>;
store.dispatch(SetCartQuantitiesAction(cart['_id'], quantities));
};17. Favoritos con Persistencia en Strapi
El tipo Favorite en Strapi sigue el mismo patrón que Cart: relación one-to-one con User y many-to-many con Product. Se crea al registrar el usuario. La Thunk Action de toggle sigue el mismo flujo bipartito: actualizar estado local → HTTP PUT con JWT:
ThunkAction<AppState> saveFavoriteAction(Product product) {
return (Store<AppState> store) async {
store.dispatch(ToggleFavoriteAction(product));
final state = store.state;
final favorites = state.products.where((p) => p.favorite).toList();
await http.put(
Uri.parse('http://10.0.2.2:1337/favorites/\${state.user['favoriteId']}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \${state.user['jwt']}',
},
body: json.encode({'products': favorites.map((p) => p.id).toList()}),
);
};
}18. Indicadores de Carga
Para bloquear interacciones duplicadas durante operaciones de red, añadimos isLoading al AppState y lo alternamos desde los Thunks:
// AppState:
final bool isLoading;
// Acción:
class SetLoadingAction { final bool value; SetLoadingAction(this.value); }
// Dentro de un Thunk:
store.dispatch(SetLoadingAction(true));
// ... petición de red ...
store.dispatch(SetLoadingAction(false));
// En la UI:
builder: (context, state) => state.isLoading
? const Center(child: CircularProgressIndicator())
: /* contenido normal */Conclusión del Capítulo
Has construido una arquitectura e-commerce completa con Flutter y Strapi. El patrón Redux separa con claridad cada responsabilidad: la UI construye widgets, las Acciones describen intenciones, los Reducers calculan el nuevo estado de forma predecible, y las Thunk Actions orquestan la comunicación asíncrona con el backend.
El resultado es una aplicación donde cualquier cambio —agregar al carrito, marcar favorito, cambiar cantidad— se refleja instantáneamente en todas las pantallas gracias a StoreConnector, y persiste entre sesiones gracias a la sincronización con MongoDB Atlas a través de Strapi.
Código fuente: https://github.com/libredesarrollo/flutter2-market-09/
Robusteciendo la App: Optimizaciones Globales y Middleware
Con el flujo principal de la tienda funcionando, esta sección aborda los problemas que aparecen en cuanto la aplicación crece y se usa en condiciones reales: bucles infinitos de peticiones, fallos de red, tokens expirados y lógica de autorización centralizada mediante Middleware.
1. El Bucle Infinito de Peticiones
Al integrar las acciones de carrito y favoritos surge un problema crítico: cuando el usuario está autenticado, la aplicación entra en un bucle infinito de peticiones. Esto ocurre porque las acciones se despachan desde el método builder del StoreConnector. Cada vez que Redux actualiza el estado, el builder se re-ejecuta, lo que vuelve a despachar las acciones, lo que vuelve a actualizar el estado, ad infinitum.
La causa raíz: colocar store.dispatch() dentro del builder es peligroso porque ese bloque se llama en cada reconstrucción del árbol de widgets. La solución es usar el callback onInit del StoreConnector, que se ejecuta una sola vez al montarse el widget, sin importar cuántas veces se reconstruya:
StoreConnector<AppState, AppState>(
converter: (store) => store.state,
// ✅ onInit se ejecuta solo una vez al entrar a la pantalla
onInit: (store) async {
final user = await store.dispatch(getUserAction);
// Solo despachar carrito y favoritos si el usuario está autenticado
if (user != null) {
store.dispatch(initCartAction);
store.dispatch(initFavoritesAction);
}
},
builder: (context, state) {
// El builder solo construye la UI, nunca despacha acciones
return /* widget */;
},
)Un detalle importante: para poder usar el resultado de getUserAction como un await, la Thunk Action tiene que retornar el usuario. Por defecto solo despacha GetUserAction al reducer pero no retorna nada. Añadir un return user; al final de la función permite encadenar la lógica de carrito y favoritos sobre ese dato.
2. El Archivo part of para Organizar las Actions
A medida que el archivo actions.dart crece con todas las acciones (usuario, productos, carrito, favoritos, errores), se vuelve difícil de mantener. Dart ofrece la directiva part of para dividir un archivo lógico en múltiples archivos físicos:
// actions.dart (archivo principal)
part 'user_actions.dart';
part 'product_actions.dart';
part 'cart_actions.dart';
// user_actions.dart
part of 'actions.dart';
class GetUserAction { ... }
ThunkAction<AppState> getUserAction = ...;Esto permite que cada conjunto de acciones viva en su propio archivo sin perder acceso a los imports del archivo principal. Es la solución idiomática de Dart para mantener la separación de responsabilidades sin multiplicar los imports en cada archivo consumidor.
3. Capturar Errores de Conexión
Cuando el servidor de Strapi no está disponible (sin internet, servidor caído, timeout), el paquete http lanza una excepción de tipo SocketException. Sin manejarlo, la excepción llega hasta Flutter y congela la aplicación.
La solución es envolver toda petición de red dentro de un bloque try/catch dentro de la Thunk Action:
ThunkAction<AppState> getProductsAction = (Store<AppState> store) async {
try {
final res = await http.get(Uri.parse('http://10.0.2.2:1337/products'));
final data = json.decode(res.body) as List<dynamic>;
store.dispatch(GetProductsAction(data));
} catch (e) {
// La aplicación sigue funcionando; solo registramos el error
print('Error de conexión: $e');
store.dispatch(SetErrorAction('No se pudo conectar al servidor.'));
}
};Con esto, si el servidor está caído la app no se congela: muestra la lista vacía y el mensaje de error al usuario. Una vez que el servidor vuelva, el usuario puede recargar manualmente.
4. Manejo de Errores con Redux: Tres Enfoques
Existen varias formas de integrar los errores al estado de Redux. El curso muestra tres variantes de menor a mayor elegancia:
4.1 Manera Manual (if/else en la Action)
Directamente en la Thunk Action verificamos el statusCode y despachamos lo que corresponda. Es el enfoque más explícito pero genera código repetido en cada acción:
if (res.statusCode == 200) {
store.dispatch(GetProductsAction(products));
} else {
store.dispatch(SetErrorAction('Error ${res.statusCode}'));
}4.2 Extrayendo la Lógica a una Función
Creamos una función reutilizable que encapsula la verificación de la respuesta. Esta función recibe la respuesta y retorna la data si es exitosa, o null si hay error:
dynamic parseResponse(http.Response res, Store<AppState> store) {
if (res.statusCode == 200) {
return json.decode(res.body);
} else {
store.dispatch(SetErrorAction('Error ${res.statusCode}'));
return null;
}
}4.3 Usando una Acción de Error en el AppState
El enfoque más limpio añade un campo error al AppState. La UI lo lee con StoreConnector y muestra el mensaje correspondiente. Esto centraliza todos los errores de la aplicación en un único lugar observable:
// En AppState:
final String? error;
// Acción:
class SetErrorAction {
final String? message;
SetErrorAction(this.message);
}
// En el reducer:
if (action is SetErrorAction) return action.message;
// En la UI:
if (state.error != null)
Text(state.error!, style: TextStyle(color: Colors.red))5. Pantalla de Carga Nuevamente (Loading State)
Para mostrar el indicador de carga mientras se resuelve una petición, añadimos el campo isLoading al AppState. El flujo correcto dentro de cualquier Thunk Action asíncrona es:
store.dispatch(SetLoadingAction(true));
try {
final res = await http.get(...);
store.dispatch(GetProductsAction(...));
store.dispatch(SetErrorAction(null)); // Limpiar errores previos
} catch (e) {
store.dispatch(SetErrorAction('Sin conexión'));
} finally {
store.dispatch(SetLoadingAction(false)); // Siempre apagar el loader
}Usar finally garantiza que el indicador de carga siempre se desactiva, incluso si ocurre una excepción. Sin este bloque, una excepción dejaría el spinner girando indefinidamente.
6. La Trampa de Clonar Listas y Objetos en Redux
Este es uno de los errores más sutiles y frecuentes al trabajar con Redux en Dart. Cuando asignas una lista o un objeto a otra variable, en Dart (como en la mayoría de lenguajes orientados a objetos) no estás creando una copia: estás apuntando a la misma posición de memoria.
// ❌ Peligroso: ambas variables apuntan a la misma lista en memoria
final cartProducts = store.state.products;
cartProducts.add(newProduct); // También modifica store.state.products
// ✅ Correcto: crear una copia independiente
final cartProducts = List.from(store.state.products);
// o usando el spread operator:
final cartProducts = [...store.state.products];Esto es especialmente crítico en Redux porque el principio de inmutabilidad exige que el estado actual nunca se modifique directamente. Si mutamos la lista original antes de hacer el dispatch, estamos rompiendo el contrato de Redux: el estado anterior y el nuevo serán el mismo objeto en memoria, lo que puede provocar que la UI no se actualice o que los cambios aparezcan en pantallas donde no deberían.
Las tres formas equivalentes de clonar una lista en Dart:
final copia1 = List.from(original); // Forma clásica
final copia2 = [...original]; // Spread operator (más moderno)
final copia3 = original.toList(); // Método de List7. Manejo de Errores 401 (Token Inválido o Expirado)
Cuando el JWT del usuario expira o se corrompe, Strapi responde con un código 401 Unauthorized a cualquier petición protegida. La aplicación debe detectarlo y reaccionar de forma inteligente, no simplemente mostrar una pantalla vacía.
La estrategia es verificar el statusCode en cada Thunk Action que use autenticación:
ThunkAction<AppState> saveCartAction(Product product) {
return (Store<AppState> store) async {
try {
final res = await http.put(
Uri.parse('http://10.0.2.2:1337/carts/${store.state.user!['cartId']}'),
headers: {'Authorization': 'Bearer ${store.state.user!['jwt']}'},
body: json.encode({...}),
);
if (res.statusCode == 200) {
store.dispatch(UpdateCartAction(product));
} else if (res.statusCode == 401) {
// Token inválido: limpiar sesión y redirigir al login
store.dispatch(LogoutAction());
store.dispatch(SetErrorAction('Sesión expirada. Inicia sesión nuevamente.'));
}
} catch (e) {
store.dispatch(SetErrorAction('Error de conexión.'));
}
};
}8. Destruir la Sesión al Recibir un 401
La acción LogoutAction debe limpiar tanto el estado global (Redux) como el estado local (SharedPreferences), para que la app arranque en un estado limpio la próxima vez:
class LogoutAction {}
// En el reducer de usuario:
if (action is LogoutAction) return null;
// Thunk de logout (limpia SharedPreferences):
ThunkAction<AppState> logoutAction = (Store<AppState> store) async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
store.dispatch(LogoutAction());
};9. Middleware: Interceptando el Flujo de Acciones
Un Middleware en Redux es una función que se sitúa entre el dispatch de una acción y el Reducer. Puede inspeccionar, modificar, bloquear o enriquecer cualquier acción antes de que llegue al reducer. Es el lugar ideal para lógica transversal: logging, autorización, analytics, reintentos automáticos.
El flujo completo con middleware es:
UI (dispatch) → [Middleware 1] → [Middleware 2] → Reducer → Nuevo Estado → UIEn el proyecto ya usamos thunkMiddleware para las acciones asíncronas. Ahora vamos a crear middlewares propios.
10. Crear un Middleware Personalizado (como Función)
Un middleware personalizado basado en funciones tiene la siguiente firma. Recibe el store, la siguiente función de la cadena (next) y la acción:
// lib/redux/middleware/auth_middleware.dart
Middleware<AppState> checkAuthStatus() {
return (Store<AppState> store, dynamic action, NextDispatcher next) {
print('Acción interceptada: ${action.runtimeType}');
// Lógica de autorización: si es una acción de función (ThunkAction)
// y el usuario NO está autenticado, bloquear la acción
if (action is Function && store.state.user == null) {
print('Acción bloqueada: usuario no autenticado');
// No llamar a next(action) equivale a bloquear la acción
return;
}
// Si todo está bien, pasar la acción al siguiente middleware o reducer
next(action);
};
}La clave del middleware es el parámetro next: si lo llamas, la acción continúa su flujo normal. Si no lo llamas, la acción queda bloqueada en este punto y nunca llega al reducer.
11. Middleware Basado en Clases
La alternativa basada en clases hereda de MiddlewareClass y sobreescribe el método call. Es más explícita y facilita la inyección de dependencias:
class AuthMiddleware extends MiddlewareClass<AppState> {
@override
void call(Store<AppState> store, dynamic action, NextDispatcher next) {
if (action is Function && store.state.user == null) {
store.dispatch(SetErrorAction('Debes iniciar sesión primero.'));
return;
}
next(action);
}
}Se registra en el Store igual que cualquier otro middleware:
final store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [
checkAuthStatus(), // Middleware de función
AuthMiddleware(), // Middleware de clase
thunkMiddleware, // Siempre al final si usas Thunks
],
);12. TypedMiddleware: Middleware Específico por Tipo de Acción
En lugar de interceptar todas las acciones y filtrar manualmente, TypedMiddleware permite enlazar un middleware solo con acciones de un tipo específico. Esto hace el código mucho más limpio y declarativo:
// Este middleware solo se ejecuta cuando se despacha ToggleCartAction
TypedMiddleware<AppState, ToggleCartAction>(
(store, action, next) {
if (store.state.user == null) {
store.dispatch(SetErrorAction('Inicia sesión para usar el carrito.'));
return; // Bloquea la acción sin llegar al reducer
}
next(action); // El usuario está autenticado: continúa
},
)Puedes pasar múltiples TypedMiddleware en el array de middlewares, cada uno respondia a un tipo de acción diferente:
middleware: [
TypedMiddleware<AppState, ToggleCartAction>(requireAuthMiddleware),
TypedMiddleware<AppState, ToggleFavoriteAction>(requireAuthMiddleware),
thunkMiddleware,
]13. El Orden de los Middlewares Importa
Los middlewares se ejecutan en el orden en que aparecen en el array. Si el thunkMiddleware está antes que tu middleware personalizado, las Thunk Actions (funciones) serán procesadas por Thunk primero y tu middleware nunca las verá como funciones.
// ❌ Si thunkMiddleware va primero, las funciones ya fueron procesadas
// y tu middleware no las puede interceptar como funciones
middleware: [thunkMiddleware, checkAuthStatus()]
// ✅ Si tu middleware va primero, puedes interceptar funciones y clases
middleware: [checkAuthStatus(), thunkMiddleware]14. Resumen: El Valor del Middleware para Autorización Global
El caso de uso principal de los middlewares en esta aplicación es la autorización centralizada. Sin middleware, cada Thunk Action tendría que incluir manualmente la verificación de si el usuario está autenticado antes de ejecutar su lógica. Con middleware, esa verificación ocurre una sola vez, de forma transparente, antes de que cualquier acción que requiera autenticación llegue al reducer.
- Sin middleware: verificar autenticación en cada una de las 10+ acciones que requieren usuario.
- Con TypedMiddleware: una sola función, registrada en el Store, se encarga de todas ellas.
Esta es la filosofía del middleware: centralizar preocupaciones transversales para que el código de negocio (las Thunk Actions y Reducers) pueda enfocarse exclusivamente en su responsabilidad principal.
15. toggleCartProductAction y changeCartProductAction: El Carrito Real
Las acciones del carrito que se usan en producción son más sofisticadas que los ejemplos conceptuales vistos anteriormente. El proyecto las organiza usando part of en lib/redux/actions/cart.dart. Hay tres operaciones principales:
15.1 toggleCartProductAction: Agregar o Eliminar un Producto
Esta Thunk Action es la piedra angular del carrito. Su nombre refleja su naturaleza toggle: si el producto ya está en el carrito, lo elimina; si no está, lo agrega. Recibe el producto y una cantidad inicial (count). La firma real del proyecto:
ThunkAction<AppState> toggleCartProductAction(Product cartProduct, int count) {
return (Store<AppState> store) async {
// 1. Clonar la lista del carrito (NUNCA mutar el estado directamente)
final List<Product> cartProducts = List.of(store.state.productsCart);
final User user = store.state.user;
// 2. Verificar autenticación antes de continuar
if (!_checkUserAuth(store)) return;
// 3. Lógica toggle: buscar el producto por ID
final int index = cartProducts.indexWhere((p) => cartProduct.id == p.id);
if (index > -1) {
// Ya existe: eliminarlo del carrito
cartProduct.cartCount = 0;
cartProducts.removeAt(index);
} else {
// No existe: agregarlo con la cantidad indicada
cartProduct.cartCount = count;
cartProducts.add(cartProduct);
}
// 4. Preparar el payload para Strapi: lista de {product_id, count}
final List<Map> cartProductsIds = cartProducts
.map((p) => {'product_id': p.id, 'count': p.cartCount})
.toList();
// 5. Persistir en el backend con JWT en el header
final res = await http.put(
Uri.parse('http://10.0.2.2:1337/carts/\${user.cartId}'),
body: {'products': json.encode(cartProductsIds)},
headers: {'Authorization': 'Bearer \${user.jwt}'},
);
// 6. Solo actualizar el estado local si el servidor confirmó el cambio
if (res.statusCode == 200) {
store.dispatch(TogleCartProductAction(cartProducts));
} else if (res.statusCode == 401) {
store.dispatch(logoutUserAction);
store.dispatch(ErrorAction(ErrorEnum.Forbidden, 'Problemas con el login'));
}
};
}Puntos clave del diseño:
List.of(store.state.productsCart): crea una copia real de la lista para evitar mutar el estado de Redux directamente (ver §6 de este capítulo)._checkUserAuth(store): función de guardia definida enactions.dartque verifica si existe un usuario en el estado. Si no hay usuario, despacha unErrorActionde tipoForbiddeny retornafalsepara cortar la ejecución del Thunk.- Payload a Strapi: en lugar de enviar objetos
Productcompletos, se envía únicamente la lista de{product_id, count}. Esto hace el payload más ligero y compatiblecon la API de Strapi. - Actualización condicional: el estado local (
TogleCartProductAction) solo se despacha si la respuesta del servidor es200 OK. Un401dispara el logout automático.
El widget CartItem lo usa de la siguiente manera: el StoreConnector convierte el store en un VoidCallback que, al ejecutarse, llama a toggleCartProductAction con count = 0 (para eliminación). Ese callback se enlaza tanto al botón de eliminar del Dismissible como al gesto de deslizar:
StoreConnector<AppState, VoidCallback>(
converter: (store) =>
() => store.dispatch(toggleCartProductAction(widget.product, 0)),
builder: (_, callback) => Dismissible(
key: ValueKey(widget.product.id),
onDismissed: (direction) => callback(), // Deslizar elimina el producto
confirmDismiss: (_) => showDialog(...), // Pide confirmación primero
child: /* Card del producto */,
),
)Nótese que el converter del StoreConnector devuelve directamente un VoidCallback (no el estado completo). Esta es una optimización importante: el widget solo se reconstruye si cambia el callback, no en cada cambio del estado global.
15.2 changeCartProductAction: Actualizar la Cantidad
Esta acción actualiza la cantidad de unidades de un producto que ya está en el carrito. Se dispara desde el campo de texto editable dentro del CartItem:
ThunkAction<AppState> changeCartProductAction(Product cartProduct, int count) {
return (Store<AppState> store) async {
final List<Product> cartProducts = store.state.productsCart;
final User user = store.state.user;
// Actualizar la cantidad localmente en la copia del estado
final int index = cartProducts.indexWhere((p) => cartProduct.id == p.id);
if (index > -1) {
cartProducts[index].cartCount = count;
}
// Sincronizar con Strapi
final List<Map> cartProductsIds = cartProducts
.map((p) => {'product_id': p.id, 'count': p.cartCount})
.toList();
final res = await http.put(
Uri.parse('http://10.0.2.2:1337/carts/\${user.cartId}'),
body: {'products': json.encode(cartProductsIds)},
headers: {'Authorization': 'Bearer \${user.jwt}'},
);
store.dispatch(ChangeCartProductAction(cartProducts));
};
}El campo de texto del carrito tiene validaciones para evitar cantidades inválidas: usa FilteringTextInputFormatter.allow(RegExp('[0-9]')) para aceptar solo dígitos, y fuerza el valor a 1 si el usuario ingresa 0 o un valor vacío. Cada cambio recalcula el precio total mostrado en la fila con setState:
onChanged: (String value) {
int n = 1;
try {
n = int.parse(value);
if (n <= 0) {
n = 1;
_quantity.text = n.toString();
}
callback(); // Despacha changeCartProductAction
} catch (e) {}
setState(() {
totalPrice = widget.product.price * n; // Actualiza el subtotal
});
},15.3 La Función de Guardia _checkUserAuth
En lugar de duplicar la verificación de autenticación en cada acción del carrito y favoritos, el proyecto extrae esa lógica a una función privada en actions.dart:
_checkUserAuth(store) {
if (store.state.user == null) {
store.dispatch(ErrorAction(ErrorEnum.Forbidden, 'Error de auth'));
}
return store.state.user != null;
}Cualquier Thunk Action que requiera autenticación lo llama al inicio:
if (!_checkUserAuth(store)) return; // Corta la ejecución si no hay usuarioEs un patrón de guardia (guard clause) que evita código duplicado y garantiza que el error de autenticación siempre se reporte al estado de forma consistente.
15.4 El Enum ErrorEnum y la Acción ErrorAction
Los errores no son simples cadenas de texto. El proyecto usa un enum para tipificar las categorías de error, lo que permite a la UI responder de forma diferente según el tipo:
enum ErrorEnum {
Ok,
ConnectionTimeOut,
Forbidden,
}
class ErrorAction {
final ErrorEnum _errorEnum;
final String _msj;
ErrorEnum get errorEnum => this._errorEnum;
String get msj => this._msj;
ErrorAction(this._errorEnum, this._msj);
}Esto permite distinguir en la UI entre un Forbidden (redirigir al login) y un ConnectionTimeOut (mostrar botón de reintentar). La lógica de presentación queda así separada limpiamente de la lógica de negocio.
Conclusión del Capítulo
En esta sección has aprendido a blindar tu aplicación Redux contra los problemas del mundo real. El bucle infinito de peticiones se resuelve usando onInit en lugar de builder. Los errores de red se capturan con try/catch y se propagan al estado con acciones de error. La mutación accidental del estado se evita clonando listas y objetos. Los tokens expirados se detectan por el código 401 y la sesión se destruye limpiamente. Y el Middleware centraliza toda la lógica de autorización, evitando código repetido en cada acción.
Estas optimizaciones son las que marcan la diferencia entre una app de prototipo y una aplicación lista para producción.
Código fuente:
https://github.com/libredesarrollo/flutter2-market-09
Siguiente paso, PhotoView o visor de fotos en Flutter