El componente donde el usuario dice "cuánto". Aparece en los 3 flujos financieros de MiCapi: compra, retiro a banco, swap. La particularidad es que el usuario puede escribir en cualquiera de las dos monedas y el sistema calcula la otra en vivo. No es un TextField genérico — es un bloque compuesto con conversión, switch de moneda, precisión variable por activo y validación contra límites KYC.
Estructura vertical. Hero amount domina visualmente. La conversión es subordinada (siempre muted). La tasa y los límites aparecen al pie, no compiten.
Caption mono uppercase. Cambia según el flujo: "Vas a pagar", "Vendes", "De". Switch a la derecha intercambia cuál moneda es el input.
Mono JetBrains Mono con tabularFigures(). Centrado horizontal. Símbolo de moneda a la izquierda ($ · ₿). Cursor blink animado en el campo activo.
Texto secundario muted, ≈ 0,00109 BTC. Se recalcula con debounce de 300ms mientras el usuario escribe. Si está calculando: skeleton inline.
Tipografía mono pequeña. Dot verde success indica tasa viva. Timestamp "hace 5s" visible. Si la tasa tiene > 30s, dot se vuelve warning + texto cambia.
Solo aparece cuando el usuario se acerca al tope mensual. El número es lo ya usado ("Usado este mes: $3.500.000 de $4.000.000"), alineado con screens-kyc-levels. La barra muestra el consumo; color primary → warning → error según proximidad.
Flutter TextInputType.numberWithOptions(decimal: true). Nunca alfanumérico. Formato locale es-CO aplicado en vivo via inputFormatter.
Cada activo tiene reglas distintas de decimales y separadores. El input debe respetarlas — confundirlas (mostrar "$500.00" en vez de "$500.000") destruye la confianza en segundos.
. · separador decimal = ,. En Flutter: NumberFormat.currency(locale: 'es_CO', symbol: '\$', decimalDigits: 0) del paquete intl. Nunca usar formato estadounidense — escribir $1,890,000 en Colombia se lee como "mil ochocientos noventa pesos con cero centavos".Redundancia anti-error de ceros, tono de banco serio: bajo el input COP aparece el monto escrito — "un millón quinientos mil pesos". Un cero de más se detecta leyendo, no contando dígitos. Solo aplica a COP: la cripta fraccional no se verbaliza.
Hasta $99.999 no aparece: montos cortos se leen de un vistazo y la línea sería ruido.
Umbral de aparición: ≥ $100.000 COP — donde empieza el riesgo real de un cero de más.
Donde más paga: distinguir $1.500.000 de $15.000.000 leyendo "un millón…" vs "quince millones…".
fg-muted, minúsculas, sin "COP" (el símbolo ya lo dijo) — deliberadamente no mono: es lectura, no dato. Va entre el hero y la conversión; entra/sale con fade (--motion-duration-fast) y se actualiza al soltar tecla, no por dígito. En Flutter: conversión número→palabras es-CO local (sin red), truncando centavos; aplica en compra, venta (COP recibido) y retiro a banco. También se muestra en el ConfirmationSheet del gate de monto alto.Mismo componente, distinto label y distintas monedas. Compra y retiro usan COP + cripto. Swap usa cripto + cripto — COP no participa.
Pagas COP, recibes cripto. Default input en COP. Switch disponible para escribir en cripto.
Vendes cripto, recibes COP en banco. Default input en cripto.
Cripto origen → cripto destino. Sin switch COP — COP no participa en swap.
Del input vacío al error de límite. Cada estado tiene tratamiento visual y, cuando aplica, mensaje de error.
Placeholder 0 en gris claro. Sin cursor visible. Tap activa el campo y abre el teclado.
Border primary-300 + ring sutil. Cursor blink. Teclado abierto. El usuario está escribiendo.
Monto ingresado. Conversión actualizada. Tasa viva con dot verde. Sin foco — el usuario terminó de escribir.
Border error + monto en rojo + mensaje. Cuando el error viene de un límite KYC, incluye el link inline "Ver mi nivel →" hacia screens-kyc-levels — estilo de link del sistema (subrayado, primary), nunca botón. Cubre: excede límite, menor al mínimo ($50.000), saldo insuficiente.
El input nunca se bloquea. Mientras se calcula la conversión, el secondary muestra un skeleton inline. El usuario sigue escribiendo.
Si la conexión se pierde o la tasa tiene > 30s. El usuario puede seguir, pero sabe que la conversión puede no ser exacta.
Los tres mensajes canónicos de límite se validan en vivo (debounce 300ms), inline bajo el hero — nunca SnackBar, nunca al confirmar. Los dos de nivel llevan link inline a "Niveles y límites" (screens-kyc-levels). El mensaje no revela topes de otros niveles — solo que existe un nivel superior.
Piso operativo, igual para todos los niveles. Sin link — no depende del nivel KYC. Aparece solo al pausar la escritura, no en el primer dígito.
Tope por operación del nivel actual. Link inline a screens-kyc-levels, donde vive la tabla de topes y la solicitud de ampliación.
Acumulado mensual agotado. El mensaje dice cuánto le queda al usuario en su indicador de límite (elemento 5 de la anatomía) — nunca los topes de N2/N3. La existencia de un nivel superior se comunica en screens-kyc-levels, no aquí.
Debajo del input, fila de chips que aceleran el caso común sin obligar a teclear. Dos juegos según la moneda del input: montos fijos en COP para compra, y porcentajes (25% · 50% · MAX) cuando el input es cripto. A2 · los porcentajes cripto se calculan SIEMPRE sobre "Disponible para retiro" — nunca sobre el saldo total. El saldo dual queda intacto.
MAX = límite KYC restante.disponibleParaRetiro; el total y lo que está en liquidación se muestran por separado y no alimentan los chips.MAX = 100% del disponible menos comisión de red estimada.lightImpact() · la conversión se dispara sin debounce (el valor ya es final).Cada interacción referencia tokens ya definidos en foundations-motion.
Formateo en vivo: escribir 1890000 se muestra como 1.890.000. El cursor mantiene posición coherente cuando aparecen separadores. CurrencyInputFormatter custom.
Crossfade del layout (duration-default + ease-standard). El valor numérico se recalcula para mantener equivalencia. Haptic selectionClick().
Timer de 300ms desde el último keystroke. No calcular en cada dígito — saturaría la red. Mientras corre el debounce: skeleton inline en el secondary.
Input se rellena instantáneo. Haptic lightImpact(). La conversión se dispara sin debounce — el valor ya es el final.
Border + monto en rojo + mensaje aparece al soltar el último dígito, no en cada tecla. Opcional: shake del input + haptic lightImpact() (sujeto a UX test, ver foundations-motion §4).
Border cambia a primary. Teclado numberWithOptions(decimal: true) aparece. Al unfocus: border vuelve a default, teclado se cierra.
Misma jerarquía. Cambian fondo, border, color del símbolo y del chip de moneda. El live dot mantiene success en ambos.
El money input es el centro de la pantalla de compra. Selector de activo arriba, money input al medio, breakdown debajo, CTA fijo al fondo. Todo conduce al confirmation sheet.
El money input domina visualmente. El hero amount es el elemento más grande de la pantalla — el usuario lee primero "cuánto va a pagar", luego lo demás.
Selector de activo arriba. Chip tappable (lleva al bottom sheet selector de activo — components-bottom-sheet variante a).
Chips de monto rápido debajo del input, no en una sección aparte. Mantiene la unidad visual del bloque.
Breakdown sin sorpresas. Tasa + comisión visible antes de continuar. Regla del DS (README §3.4): la información financiera nunca se oculta en T&C.
"Continuar" abre el confirmation sheet (components-bottom-sheet variante b, persistente). La acción de comprar es un acto deliberado de dos pasos.
Crear MoneyInput con parámetros: flow (buy/withdraw/swap), primaryCurrency, secondaryCurrency, maxAmount, minAmount, usedAmount, callbacks de cambio.
TextField con keyboardType: TextInputType.numberWithOptions(decimal: true) + inputFormatters: [CurrencyInputFormatter(locale: 'es_CO', decimals: 0)] custom.
Timer de 300ms reiniciado en cada onChanged. Cuando expira, llama al provider de tasa y actualiza el secondary. Mientras corre: SkeletonBox inline.
AnimatedSwitcher(duration: 200ms, child: ...). Al tocar, intercambia primaryCurrency y secondaryCurrency; el numérico se recalcula. Haptic selectionClick().
TextStyle(fontFeatures: [FontFeature.tabularFigures()]) en el hero amount. Garantiza que 1.890.000 y 9.999.999 tengan el mismo ancho horizontal.
Provider de validación calcula errorMessage en cada cambio del valor. El componente recibe el error: String? como input y renderiza el state d) si no es null.
// CurrencyInputFormatter — locale es-CO class CurrencyInputFormatter extends TextInputFormatter { final int decimals; final String locale; const CurrencyInputFormatter({this.decimals = 0, this.locale = 'es_CO'}); TextEditingValue formatEditUpdate(TextEditingValue oldVal, TextEditingValue newVal) { final raw = newVal.text.replaceAll(RegExp(r'[^\d]'), ''); if (raw.isEmpty) return const TextEditingValue(text: ''); final value = int.parse(raw); final formatted = NumberFormat.decimalPattern(locale).format(value); return TextEditingValue( text: formatted, selection: TextSelection.collapsed(offset: formatted.length), ); } } // Conversión debounced Timer? _debounce; void _onAmountChanged(String value) { _debounce?.cancel(); setState(() => _isCalculating = true); _debounce = Timer(const Duration(milliseconds: 300), () async { final converted = await ref.read(rateProvider).convert(value, from: primary, to: secondary); if (mounted) setState(() { _convertedAmount = converted; _isCalculating = false; }); }); } // Currency switch void _switchCurrency() { HapticFeedback.selectionClick(); setState(() { final tmp = primary; primary = secondary; secondary = tmp; // recalc el valor numérico para mantener equivalencia _amount = _convertedAmount; _convertedAmount = tmp == Currency.cop ? _amount / rate : _amount * rate; }); }