Resumen. Esto no es un argumento contra que la IA escriba Flutter. Los agentes escriben buena parte de nuestro código, y diseñamos nuestro proceso alrededor de eso a propósito. Es un argumento sobre qué pasa cuando nadie los orquesta. Entregamos Flutter con agentes y auditamos Flutter que escribieron agentes sin supervisión, y los mismos siete huecos separan a unos de otros. Un agente sin supervisión recalcula el estado derivado en todas partes en vez de mantener una única fuente de verdad; no escribe tests ni benchmarks, así que la revisión es la única puerta y las regresiones pasan libres; colapsa el asíncrono complejo en pirámides de if y switch sobre banderas sueltas en vez de modelarlo como un stream; trata los tokens de diseño como constantes en un constants.dart en lugar del ThemeData que el framework ya le da; inventa un vocabulario de UX nuevo en cada pantalla, porque no puede ver la app; concatena cadenas donde las reglas de plural de ICU son obligatorias; y simplifica de forma uniforme, aplanando las partes del dominio donde la complejidad es justamente el punto. Nada de esto es un problema de Flutter, y nada de esto significa que el código no valga. Significa que las decisiones de sistema completo siguen siendo nuestras.
Por qué Flutter castiga esto más que la mayoría de los stacks
Cada uno de estos fallos aparece en todos los lenguajes. Flutter simplemente los hace más sonoros, por cuatro razones estructurales.
La interfaz es código, hasta el fondo. No hay una hoja de estilos fuera del código fuente que mantenga la coherencia, ni cascada, ni un CSS compartido que un segundo desarrollador esté obligado a ver. Cada píxel es una expresión de Dart. Si la coherencia no se impone dentro del código, no la impone nada más.
Todo compila. Flutter ofrece unas diez formas razonables de hacer cualquier cosa, y las diez funcionan. No hay un error de compilación para «aquí deberías haber usado el tema», ni un aviso para «es la tercera vez que derivas este total», ni un lint para «esta pantalla carga distinto que las otras treinta y nueve». La realimentación que el agente necesita no existe salvo que alguien la construya.
El agente nunca ve el fotograma. Escribe una pantalla, obtiene un flutter analyze limpio y reporta éxito, sobre una maquetación que desborda a 320 pt, esconde el botón de envío tras el teclado y falla el contraste en modo oscuro. Una persona nota las tres en dos segundos de mirar. Un agente no tiene ojos salvo que se los des.
Los datos de entrenamiento son una década de Flutter mezclado. El framework se mueve rápido y el corpus no. Así que los agentes escriben con seguridad RaisedButton (eliminado en la 3.0), WillPopScope (sustituido por PopScope tras la 3.12), MediaQuery.of(context).textScaleFactor (reemplazado por TextScaler en la 3.16), MaterialStateProperty (renombrado WidgetStateProperty en la 3.19), Color.withOpacity (obsoleto en favor de withValues en la 3.27) y ThemeData.accentColor, que se eliminó en la 3.7 y lleva años sin existir. No es que el modelo se equivoque: es que está respondiendo para una versión de Flutter que ya no usa nadie, y la mitad todavía compila con un aviso de obsolescencia que nadie lee.
Vamos con los siete.
1. Una única fuente de verdad, o recalcularla en todas partes
Qué hace el agente. Deriva el mismo valor en cada sitio que lo necesita. El total del carrito se pliega en la página del carrito. Se pliega otra vez en el pie fijo del checkout. Se pliega una tercera vez en el resumen del pedido, y esa aplica el descuento antes de impuestos en vez de después. Nada está mal en ningún archivo por separado; los tres simplemente no coinciden, y la discrepancia es invisible hasta que a un cliente le cobran la cantidad equivocada.
La versión específica de Flutter es peor: replicar el estado dentro de un widget.
class _CartPageState extends State<CartPage> {
double _total = 0; // copy #2 of the truth
@override
void initState() {
super.initState();
_total = widget.items.fold(0, (sum, i) => sum + i.price * i.qty);
}
// ...and now _total never updates when the cart does
}
Esta es la copia clásica en initState. Funciona en la demo porque la demo nunca cambia el carrito después de abrir la página. Se rompe la primera vez que se edita una cantidad desde una hoja inferior.
Qué hacemos. El valor se deriva exactamente una vez, en la capa de estado, y todos los widgets lo leen. Si puede calcularse a partir del estado, no es estado.
// One derivation. The cart page, the footer and the summary all read this.
Stream<Money> get total => _cart.map(
(c) => c.items.fold(Money.zero, (sum, i) => sum + i.subtotal),
);
Fíjate en el tipo Money en vez de double. Los agentes recurren a double para el dinero constantemente, y 0,1 + 0,2 != 0,3 no es académico cuando es el saldo de alguien.
Por qué el agente acaba ahí. Encontrar la derivación existente cuesta contexto; regenerarla en línea no cuesta nada. Cada prompt empieza de cero, así que «¿esto ya está calculado en alguna parte?» es una pregunta que estructuralmente no puede hacerse. Cada copia es razonable localmente. La deriva es lo que pagas.
2. Tests y benchmarks, o esperanza
Qué hace el agente. Entrega la funcionalidad y para. Sin tests de widget, sin goldens, sin benchmark. La definición de terminado es «compila y la pantalla parece plausible según la descripción que escribí de ella».
Eso deja la revisión de código como única puerta de calidad, y esta es la parte que la gente subestima. La revisión ya era el cuello de botella cuando el código lo escribían humanos a velocidad humana. Apunta tres agentes a una base de código y el volumen de diff que llega a esa puerta sube un orden de magnitud mientras el número de personas capacitadas para revisarlo sigue siendo uno. La revisión no escala. Las comprobaciones ejecutables sí.
Qué hacemos. Escribimos los tests y los benchmarks primero, y no como ejercicio de virtud, sino como el bucle de realimentación que le falta al agente.
- Tests de widget para el comportamiento: el botón se deshabilita mientras se envía, el error se limpia al reescribir, la lista pagina en el desplazamiento correcto.
- Golden tests para la apariencia. Es lo de mayor palanca que puedes darle a un agente trabajando en interfaz, porque un golden es lo más parecido a darle ojos.
matchesGoldenFileconvierte «¿el rediseño ha roto el estado vacío a 320 pt en modo oscuro con el texto al 200 %?» en una comprobación que corre en CI en cuatro segundos. Sin goldens, esa pregunta solo la responde una persona abriendo la app, es decir, a veces. - Benchmarks para las cosas que se pudren en silencio. El presupuesto de fotograma son 16,6 ms; una lista que da tirones después de que alguien añada una sombra y un
Opacitydentro del constructor del elemento es una regresión que ningún test unitario detecta. Los tiempos de fotograma en una build de perfil, comprobados contra un presupuesto, la detectan. - Un test de regresión por cada bug que arreglamos, para que el agente que lo arregla no pueda desarreglarlo tres prompts después.
El patrón que hay que interiorizar: un agente es excelente satisfaciendo una comprobación que puede ejecutar, e indefenso donde no existe ninguna. Dale flutter test e iterará hasta ponerlo en verde. No le des nada y te dirá que está seguro. Nuestro artículo de hallazgos de auditoría cubre qué aspecto tiene una base de código tras un año de la segunda opción.
3. Streams, o una pirámide de banderas
Qué hace el agente. Representa el estado asíncrono como campos paralelos sueltos y después ramifica sobre ellos.
bool _isLoading = false;
String? _error;
List<Hit> _items = [];
// build():
if (_isLoading) return const CircularProgressIndicator();
if (_error != null) return Text(_error!);
if (_items.isEmpty) return const Text('Nothing here');
return ListView(/* ... */);
Tres campos, ocho combinaciones representables, cuatro de ellas sin sentido. ¿Qué se renderiza cuando _isLoading es cierto y _error tiene valor? Lo que dicte el orden de los if. Después llega el buscador, y el agente añade un Timer para el debounce, un contador _requestId para descartar respuestas obsoletas y una comprobación de mounted antes de cada setState: una reimplementación casera y sutilmente rota de switchMap. A su lado crece un utils.dart de helpers estáticos, porque la lógica no tiene ningún sitio estructural donde vivir.
Esta es la forma concreta: no es que los agentes eviten los switch, sino que hacen switch sobre banderas que ellos mismos ponen a mano en vez de sobre un tipo que el compilador pueda comprobar.
Qué hacemos. El estado es una jerarquía sellada, así que las combinaciones ilegales son irrepresentables, y el stream se encarga de los tiempos.
sealed class SearchState {}
final class Idle extends SearchState {}
final class Loading extends SearchState {}
final class Failed extends SearchState { Failed(this.error); final AppError error; }
final class Loaded extends SearchState { Loaded(this.hits); final List<Hit> hits; }
// build() — the compiler enforces that every case is handled
return switch (state) {
Idle() => const SearchHint(),
Loading() => const AppSpinner(),
Failed(:final error) => AppErrorView(error),
Loaded(:final hits) when hits.isEmpty => const EmptyResults(),
Loaded(:final hits) => HitList(hits),
};
Y la parte asíncrona —debounceTime, switchMap y startWith son extensiones de rxdart sobre Stream, no de dart:async— donde la versión de banderas compite en silencio:
// Debounce and cancellation are declarative. A stale query cannot win,
// because switchMap already cancelled its subscription.
Stream<SearchState> get states => _query
.debounceTime(const Duration(milliseconds: 300))
.switchMap(_search)
.startWith(Idle());
Añade un estado nuevo —RateLimited, por ejemplo— y el compilador te lista todos los switch que hay que actualizar. En la versión de banderas, añadir un estado significa encontrar cada cadena de if a mano y cruzar los dedos.
Por qué el agente acaba ahí. Las banderas imperativas son el patrón más común del corpus y el más fácil de producir línea a línea. Un stream exige sostener toda la máquina de estados en la cabeza a la vez, que es exactamente aquello en lo que un generador con contexto limitado es peor. Es el mismo fallo que describimos como infierno de callbacks en las auditorías, solo que vestido de Dart.
4. Conocer el framework: ThemeData no es un archivo de constantes
Esta es en la que el conocimiento del framework se nota con más claridad.
Qué hace el agente. Crea constants.dart, lo llena de static const primary = Color(0xFF6750A4) y después escribe estilos en línea en cada punto de uso.
Text(
'Balance',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)
Esa única línea tiene cuatro problemas distintos, y solo el primero es obvio:
- El modo oscuro pasa a ser un
ifmanual.AppColors.textPrimaryes un color. Soportar un segundo tema significa un ternario sobreTheme.of(context).brightnessen cada uno de los 200 puntos de uso, que es exactamente lo que los agentes escriben después, pantalla a pantalla. - El cambio de marca es un diff de 400 archivos. Que un diseñador cambie la escala tipográfica debería ser un cambio en un archivo. Aquí es un buscar y reemplazar por toda la app, y los sitios que se desviaron (
fontSize: 15en dos pantallas porque esa generación redondeó distinto) se quedarán fuera. - El escalado de texto del sistema rompe la maquetación. Tamaños fijos compuestos contra rellenos fijos hacen que un usuario al 200 % de escala de accesibilidad obtenga desbordamiento, no reflujo.
- Los override de
Theme.of(context)dejan de funcionar. Envolver un subárbol en un tema distinto —una sección oscura en una pantalla clara, un checkout de marca dentro de una app neutra— no hace nada, porque nada del subárbol le pregunta nada al tema.
Qué hacemos. El sistema de diseño vive en ThemeData, una vez, en la raíz.
MaterialApp(
theme: AppTheme.light, // ColorScheme.fromSeed + TextTheme + component themes
darkTheme: AppTheme.dark,
// ...
)
// Every widget asks the framework instead of a constants file:
Text('Balance', style: Theme.of(context).textTheme.titleMedium)
Los temas de componente (FilledButtonThemeData, CardThemeData, InputDecorationThemeData) hacen que un botón se vea bien por defecto, así que el agente que produce la vigésima pantalla no puede equivocarse aunque lo intente. Para los tokens de marca a los que Material no da hueco —una paleta de gráficos, un degradado, un par semántico de «positivo/negativo» en una app fintech— una ThemeExtension los mantiene dentro de la misma búsqueda en vez de al lado:
final class BrandColors extends ThemeExtension<BrandColors> {
const BrandColors({required this.positive, required this.negative});
final Color positive;
final Color negative;
// copyWith / lerp ...
}
// usage
final brand = Theme.of(context).extension<BrandColors>()!;
El modo oscuro cuesta entonces un ThemeData más, no un ternario por widget. En Arcana todo el sistema de diseño a medida se expresa así, y por eso una revisión de diseño allí es un diff que puedes leer en una sola revisión.
Por qué el agente acaba ahí. Una constante es localmente correcta y satisface de inmediato: el color aparece, la pantalla compila. Enhebrar un sistema de diseño a través del framework solo se rentabiliza en muchas pantallas y con un segundo tema, y el retorno-a-lo-largo-de-toda-la-app es precisamente el eje que un optimizador por prompt no puede ver.
5. Ojos, y una década entregando
Qué hace el agente. Construye cuarenta pantallas, cada una defendible por sí sola, que juntas se sienten como una app ensamblada a partir de cuarenta tutoriales, porque en cierto sentido lo fue.
La carga es un CircularProgressIndicator en la pantalla de inicio, un esqueleto de shimmer en el feed y un logo animado a medida en el perfil. Los errores son un SnackBar aquí, texto rojo en línea allá y un AlertDialog modal en la tercera pantalla. Una confirmación destructiva es un AlertDialog en una pantalla y una hoja inferior en la siguiente. Los estados vacíos tienen tres ilustraciones distintas, dos tonos de voz diferentes y uno que es solo la palabra «Vacío». Algunos formularios validan en cada pulsación, otros al enviar y uno al perder el foco. Los botones primarios ocupan todo el ancho en un flujo y se ajustan al contenido en otro.
Cada una de esas es una decisión razonable. El problema es que un usuario aprende una app una sola vez. Cuando la misma acción se ve distinta en tres sitios, deja de confiar en que sabe lo que va a pasar, y ese coste aparece en los tickets de soporte, no en un informe de lint.
Después está la clase de defectos que solo existen en una pantalla:
RenderFlex overflowed by 27 pixelsen cualquier dispositivo más estrecho de lo que imaginó el agente- objetivos táctiles por debajo de 44/48 dp, que pasan los tests y se fallan constantemente con un pulgar real
- el teclado tapando el campo en el que se escribe, porque nada hace scroll
- texto truncado al 200 % de escala del sistema
- contenido bajo el notch o detrás del indicador de inicio, porque
SafeAreano formaba parte del prompt - contraste que pasa en modo claro y falla en oscuro
flutter analyze está limpio para todos y cada uno de ellos.
Qué hacemos. Decidimos el vocabulario de interacción una vez —cómo se ve la carga, cómo aparecen los errores, cómo se confirman las acciones destructivas, cuándo validan los formularios, cómo se comporta la navegación, qué dice un estado vacío— y después cada pantalla implementa eso. Más de diez años entregando compran sobre todo saber qué patrones tienen ya los usuarios en los dedos, porque se los pusieron ahí otros miles de apps. Lo familiar gana a lo ingenioso prácticamente siempre, y la forma de comprobarlo es abrir la app en un dispositivo real, a una escala de texto real, en ambos temas y con un pulgar.
Por qué el agente acaba ahí. Genuinamente no puede ver. Está generando una pantalla plausible a partir de una descripción, sin memoria de las treinta y nueve pantallas anteriores y sin imagen de la que acaba de producir. La plausibilidad local es el único objetivo a su alcance.
6. ICU no es opcional, y concatenar no es localizar
Construimos apps para mercados donde equivocarse en esto se ve en la primera frase de la primera pantalla. Los agentes se equivocan por defecto.
Qué hace el agente.
Text('$count items'); // 1 items
Text('$count ${count == 1 ? "item" : "items"}'); // "clever", still broken
Text('Hello, ' + name + '!'); // English word order, baked in
Text('${d.day}.${d.month}.${d.year}'); // wrong half the world
Text('\$${amount.toStringAsFixed(2)}'); // wrong separator, wrong position
El plural con ternario booleano es el instructivo, porque parece que el desarrollador lo pensó. Codifica una suposición que es cierta en inglés y falsa casi en todas partes: que un idioma tiene exactamente dos formas de plural. El ruso tiene cuatro categorías (one, few, many, other) —1 файл, 2 файла, 5 файлов—, así que el ternario está mal para la mayoría de los números en pantalla. El árabe tiene seis. El japonés tiene una, y pluralizar siquiera se lee como roto. Ninguna cantidad de ternarios anidados arregla esto, porque la regla son datos por idioma, no una condición que escribes.
Lo mismo aplica más abajo. La concatenación hornea la sintaxis inglesa en el código, así que un traductor no puede reordenar la frase. Los separadores decimales, los de miles y la posición del símbolo de moneda son datos de locale (1 234,56 € frente a $1,234.56). El orden de la fecha es dato de locale. Y en maquetaciones de derecha a izquierda, EdgeInsets.only(left: 16) —que los agentes escriben por reflejo— pone el relleno en el lado equivocado, donde EdgeInsetsDirectional.only(start: 16) habría sido lo correcto.
Qué hacemos. Los mensajes viven en archivos ARB con ICU MessageFormat, generados en accesos tipados por gen_l10n:
{
"unreadMessages": "{count, plural, =0{No new messages} one{{count} new message} other{{count} new messages}}",
"@unreadMessages": { "placeholders": { "count": { "type": "int" } } }
}
El ARB ruso para esa misma clave lleva one/few/many/other: el formato tiene hueco para el idioma, y el framework elige la rama correcta a partir de los datos de CLDR. NumberFormat.currency y DateFormat.yMMMd(locale) se encargan del resto, y los insets direccionales mantienen honesto el RTL. El punto de uso pasa a ser Text(l10n.unreadMessages(count)), que es a la vez más corto que el ternario y correcto.
Por qué el agente acaba ahí. La interpolación de cadenas es el camino más corto hasta texto en pantalla, y es visiblemente correcta en el idioma en el que se escribió el prompt. El fallo solo aparece en un idioma sobre el que nunca se le preguntó, con un número que nunca probó.
7. Saber dónde simplificar, y dónde no
El último es criterio, y es el más difícil de delegar.
Qué hace el agente. Aplica la simplificación de forma uniforme, lo que produce dos fallos opuestos en la misma base de código.
Aplana complejidad que era estructural:
try {
await api.charge(order);
} catch (_) {
// swallowed: network failure, declined card, and idempotency conflict
// are now the same event, and the retry will double-charge
}
La lógica de reintentos e idempotencia se colapsa en una sola llamada porque «parecía redundante». Una condición de carrera al refrescar un token recibe un simple await porque el mutex «parecía sin usar». Modos de fallo distintos se fusionan en un único Algo ha salido mal, que es precisamente el mensaje que hace irreproducible un bug de pagos. Los tokens de cancelación se eliminan porque ningún test los referenciaba.
Y sobreabstrae cosas que estaban bien: un AppButton con catorce parámetros con nombre opcionales y seis booleanos, una jerarquía genérica BaseRepository<T, ID> sobre tres endpoints, un AbstractBaseViewModel del que nadie puede heredar sin leerlo entero, y el inevitable utils.dart donde van a morir cuarenta funciones estáticas sin relación.
Qué hacemos. La complejidad es un presupuesto, y se gasta donde el dominio es genuinamente difícil: pagos, sincronización offline y resolución de conflictos, refresco de tokens de autenticación, idempotencia, cualquier cosa que toque dinero o identidad. Esos sitios se quedan explícitos, verbosos y aburridos a propósito, con cada modo de fallo nombrado y cada reintento deliberado. En todos los demás colapsamos con ambición: cinco elementos de lista casi idénticos se convierten en un widget, la pantalla de ajustes no recibe capa de abstracción alguna y un repositorio que envuelve un endpoint es simplemente una función.
Por qué el agente acaba ahí. «¿Qué partes de este dominio harán daño a alguien si están mal?» no es información que exista en el código. Viene del producto, del contexto regulatorio y de haber estado en la llamada del incidente. Un agente que optimiza diffs legibles y mínimos trata un bucle de reintento de pago y un interruptor de ajustes como el mismo tipo de cosa, porque sintácticamente lo son.
Qué significa realmente orquestar
Lee los siete juntos y la forma queda clara: cada uno es una propiedad de sistema completo —coherencia, cobertura, un modelo de estado, un sistema de diseño, un vocabulario de interacción, un modelo de locales, un presupuesto de complejidad—. Ninguno puede emerger de prompt en prompt, porque ningún prompt ve el conjunto. Eso no es un defecto que se arregle con mejores prompts; es lo que es la ventana de contexto.
Así que el trabajo del ingeniero se mueve hacia arriba, y es sobre todo cuatro cosas:
Fijar los invariantes antes de que exista el código. El tema, el modelo de estado, la configuración de localización, la estructura de carpetas, la taxonomía de errores. Una vez que existen, «sigue el patrón existente» tiene algo a lo que señalar, y los agentes son genuinamente buenos siguiendo un patrón que ya está en el repositorio. Los dos primeros días deciden los seis meses siguientes, y por eso tienen una fase propia en nuestro proceso de desarrollo.
Construir los bucles de realimentación que el agente no tiene. Un analysis_options.yaml estricto con los lints promovidos a errores. flutter test con cobertura de widget y golden. Un presupuesto de tiempo de fotograma comprobado en builds de perfil. Cada uno de ellos convierte un juicio en una comprobación que el agente puede ejecutar e iterar por su cuenta, que es la única forma de que su velocidad sea un activo y no un pasivo.
Revisar decisiones, no líneas. ¿Es este el mismo modelo de estado que el resto de la app? ¿Está esto estilizado desde el tema? ¿Es esta la única derivación de ese valor? ¿Sobrevivirá esta cadena al ruso? La revisión línea a línea de la salida de un agente no escala y nunca escalará; la revisión a nivel de decisión sí, porque las decisiones son pocas.
Abrir la app. En un dispositivo, al 200 % de escala de texto, en modo oscuro, en ambos idiomas, con la red limitada. Esto lleva diez minutos y encuentra toda la categoría de defectos que ninguna comprobación estática reportará jamás.
Esa es la diferencia real entre Flutter escrito por un agente y Flutter escrito por un equipo orquestando agentes. No el código: el código a menudo está bien. Las restricciones a su alrededor.
Preguntas frecuentes
¿Trabajando sobre una app Flutter construida por un agente?
Si reconoces tu base de código en esta lista, no es señal de que usar IA fuera un error: es el aspecto que tiene la salida de un agente sin supervisión en Flutter, siempre, y se arregla sin reescribir.
Construimos Flutter así para vivir: los agentes escriben buena parte del código y nuestros ingenieros son dueños de las siete decisiones de arriba. Echa un vistazo a cómo hacemos desarrollo de apps con Flutter, pasa una base de código existente por una auditoría de código IA, o reserva una evaluación gratuita y te diremos cuál de estos siete te está costando más.

