Component · Money Input

Money Input

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.

Conversión en vivo locale es-CO · 1.890.000 · 0,00109 Switch de moneda · selectionClick haptic Validación de límites KYC
Anatomy

Cinco elementos en un bloque

Estructura vertical. Hero amount domina visualmente. La conversión es subordinada (siempre muted). La tasa y los límites aparecen al pie, no compiten.

Vas a pagar
COP
$ 500.000
≈ 0,00109 BTC
1 BTC = $458.320.000 COP · hace 5s
Usado este mes $3.500.000 de $4.000.000
1

Label · moneda activa

Caption mono uppercase. Cambia según el flujo: "Vas a pagar", "Vendes", "De". Switch a la derecha intercambia cuál moneda es el input.

2

Hero amount · 32dp tabular

Mono JetBrains Mono con tabularFigures(). Centrado horizontal. Símbolo de moneda a la izquierda ($ · ₿). Cursor blink animado en el campo activo.

3

Conversión en vivo

Texto secundario muted, ≈ 0,00109 BTC. Se recalcula con debounce de 300ms mientras el usuario escribe. Si está calculando: skeleton inline.

4

Tasa actual · live dot

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.

5

Indicador de límite KYC

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.

6

Teclado · numérico decimal

Flutter TextInputType.numberWithOptions(decimal: true). Nunca alfanumérico. Formato locale es-CO aplicado en vivo via inputFormatter.

Decimal precision

Precisión por moneda

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.

Moneda
Decimales input
Conversión
Sep. miles
Sep. decimal
Ejemplo formateado
$
COP
0 (sin centavos)
0
.
N/A
$1.890.000
₿
BTC
8
8
N/A
,
0,00109508 BTC
ETH
8 (truncado de 18)
8
N/A
,
0,21560000 ETH
USDT
2
2
.
,
45,00 USDT
Localización Colombia. Separador de miles = . · 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".
Amount in words · A4

Monto en palabras

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.

a · BAJO EL UMBRAL

Hasta $99.999 no aparece: montos cortos se leen de un vistazo y la línea sería ruido.

Pagas
COP
$ 80.000
Recibes ≈0,00017 BTC

b · DESDE $100.000

Umbral de aparición: ≥ $100.000 COP — donde empieza el riesgo real de un cero de más.

Pagas
COP
$ 500.000
quinientos mil pesos
Recibes ≈0,00109 BTC

c · MILLONES

Donde más paga: distinguir $1.500.000 de $15.000.000 leyendo "un millón…" vs "quince millones…".

Pagas
COP
$ 1.500.000
un millón quinientos mil pesos
Recibes ≈0,00327 BTC
Tratamiento. Sans 12.5px medium en 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.
Flow variants

Tres flujos financieros

Mismo componente, distinto label y distintas monedas. Compra y retiro usan COP + cripto. Swap usa cripto + cripto — COP no participa.

a · COMPRA

Pagas COP, recibes cripto. Default input en COP. Switch disponible para escribir en cripto.

Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
COPBTC

b · RETIRO

Vendes cripto, recibes COP en banco. Default input en cripto.

Vendes
BTC
₿ 0,00109
Recibes ≈$500.000 COP
BTCCOP

c · SWAP

Cripto origen → cripto destino. Sin switch COP — COP no participa en swap.

De
ETH
Ξ 0,1500
A ≈135,42 USDT
ETHUSDT
States

Seis estados del input

Del input vacío al error de límite. Cada estado tiene tratamiento visual y, cuando aplica, mensaje de error.

a · EMPTY

Pagas
COP
$ 0
Recibes ≈0 BTC

Placeholder 0 en gris claro. Sin cursor visible. Tap activa el campo y abre el teclado.

b · FOCUSED

Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC

Border primary-300 + ring sutil. Cursor blink. Teclado abierto. El usuario está escribiendo.

c · FILLED

Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
1 BTC = $458.320.000 COP

Monto ingresado. Conversión actualizada. Tasa viva con dot verde. Sin foco — el usuario terminó de escribir.

d · ERROR

Pagas
COP
$ 5.000.000
Máximo $4.000.000 por operación en tu nivel. Ver mi nivel →

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.

e · LOADING

Pagas
COP
$ 500.000
Recibes ≈

El input nunca se bloquea. Mientras se calcula la conversión, el secondary muestra un skeleton inline. El usuario sigue escribiendo.

f · RATE STALE

Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
Tasa no actualizada · hace 45s

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.

Errores de tope · inline mientras se escribe

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.

d1 · MÍNIMO POR OPERACIÓN

Pagas
COP
$ 30.000
Mínimo $50.000 por operación.

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.

d2 · MÁXIMO POR OPERACIÓN

Pagas
COP
$ 5.000.000
Máximo $4.000.000 por operación en tu nivel. Niveles y límites →

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.

d3 · DISPONIBLE MENSUAL

Pagas
COP
$ 3.200.000
Excede tu disponible este mes. Niveles y límites →

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í.

Quick amount chips

Atajos de monto

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.

a · Compra · input COP · montos fijos
Pagas
COP
$ 100.000
Recibes ≈0,00082 BTC
b · Retiro / swap · input cripto · porcentajes
Vendes
BTC
₿ 0,00175
Recibes ≈$802.060 COP
Disponible para retiro 0,00350 BTC
En liquidación (no disponible) 0,00062 BTC

Reglas

  • Compra (COP): montos fijos $50.000 · $100.000 · $500.000 — típicos en Colombia — + MAX = límite KYC restante.
  • Retiro / swap (cripto): porcentajes 25% · 50% · MAX calculados siempre sobre "Disponible para retiro", nunca sobre el saldo total. La cripto en liquidación se muestra pero no entra en el cálculo.
  • Saldo dual intacto: el componente lee disponibleParaRetiro; el total y lo que está en liquidación se muestran por separado y no alimentan los chips.
  • MAX y "Usar máx" son equivalentes: chip y link de texto rescatado del pattern. MAX = 100% del disponible menos comisión de red estimada.
  • Tap → input se rellena · haptic lightImpact() · la conversión se dispara sin debounce (el valor ya es final).
  • Chip seleccionado mantiene highlight hasta que el usuario cambia el valor manualmente.
Interactions

Interacciones

Cada interacción referencia tokens ya definidos en foundations-motion.

Escribir dígito

Formateo en vivo: escribir 1890000 se muestra como 1.890.000. El cursor mantiene posición coherente cuando aparecen separadores. CurrencyInputFormatter custom.

Switch de moneda

Crossfade del layout (duration-default + ease-standard). El valor numérico se recalcula para mantener equivalencia. Haptic selectionClick().

Conversión en vivo

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.

Tap en chip de monto rápido

Input se rellena instantáneo. Haptic lightImpact(). La conversión se dispara sin debounce — el valor ya es el final.

Error de validación

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).

Focus / unfocus

Border cambia a primary. Teclado numberWithOptions(decimal: true) aparece. Al unfocus: border vuelve a default, teclado se cierra.

Dark mode

Claro y oscuro

Misma jerarquía. Cambian fondo, border, color del símbolo y del chip de moneda. El live dot mantiene success en ambos.

LightDEFAULT
Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
1 BTC = $458.320.000 COP
DarkDARK MODE
Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
1 BTC = $458.320.000 COP
In context

En la pantalla de compra

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.

9:41
Comprar Bitcoin
₿
Bitcoin
$458.320.000 / BTC
Pagas
COP
$ 500.000
Recibes ≈0,00109 BTC
1 BTC = $458.320.000 COP
Tasa$458.320.000 / BTC
Comisión MiCapi (1,5%)$7.500
Total a pagar$507.500
Continuar

Observaciones

1

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.

2

Selector de activo arriba. Chip tappable (lleva al bottom sheet selector de activo — components-bottom-sheet variante a).

3

Chips de monto rápido debajo del input, no en una sección aparte. Mantiene la unidad visual del bloque.

4

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.

5

"Continuar" abre el confirmation sheet (components-bottom-sheet variante b, persistente). La acción de comprar es un acto deliberado de dos pasos.

Implementation

Notas de implementación Flutter

Construcción del money input

Widget compuesto

Crear MoneyInput con parámetros: flow (buy/withdraw/swap), primaryCurrency, secondaryCurrency, maxAmount, minAmount, usedAmount, callbacks de cambio.

Input principal

TextField con keyboardType: TextInputType.numberWithOptions(decimal: true) + inputFormatters: [CurrencyInputFormatter(locale: 'es_CO', decimals: 0)] custom.

Conversión debounced

Timer de 300ms reiniciado en cada onChanged. Cuando expira, llama al provider de tasa y actualiza el secondary. Mientras corre: SkeletonBox inline.

Switch de moneda

AnimatedSwitcher(duration: 200ms, child: ...). Al tocar, intercambia primaryCurrency y secondaryCurrency; el numérico se recalcula. Haptic selectionClick().

Tabular figures

TextStyle(fontFeatures: [FontFeature.tabularFigures()]) en el hero amount. Garantiza que 1.890.000 y 9.999.999 tengan el mismo ancho horizontal.

Validación

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;
  });
}