Cada nivel tiene un objetivo, una lista de ejercicios, errores típicos y una definición de “hecho”. No pases al siguiente nivel por terminar un ejercicio: pasa cuando puedas explicar el nivel.
Nivel 1 — Sintaxis y control de flujo
Objetivo: escribir funciones pequeñas sin pelear con la sintaxis.
| Ejercicio | Concepto | Pista |
|---|---|---|
greet | String templates | "Hola, $name" |
isEven | Operador módulo | value % 2 == 0 |
maxOfThree | Expresiones | maxOf(a, b, c) |
fizzBuzz | when | Ordena las condiciones de 15, 3, 5 |
safeDivide | Nullable | if (b == 0) null else a / b |
parsePositiveInt | toIntOrNull + takeIf | trim() antes de parsear |
Errores típicos:
Usar
if/elseanidados en vez dewhen.Devolver
0en vez denullpara un error.Olvidar que
Int?yIntson tipos distintos.Escribir
returnen una función de expresión.
Definición de hecho: los 6 tests pasan y puedes explicar la diferencia entre Int e Int?.
Nivel 2 — Colecciones y lambdas
Objetivo: transformar datos como lo harías con where, map, groupBy y fold en Dart.
| Ejercicio | Concepto | Pista |
|---|---|---|
totalCents | sumOf | No uses un for si sumOf se lee mejor |
filterByQuery | filter + contains(ignoreCase = true) | Query vacía devuelve todo |
groupByCategory | groupBy | El resultado es Map<String, List<Transaction>> |
topCounterparties | groupBy + mapValues + sortedWith | Ordena por suma descendente; desempata por nombre |
formatCents | Lógica con Long | Separa unidades y decimales; no uses Double |
wordCount | Regex + groupingBy + eachCount | Ignora signos y mayúsculas |
Errores típicos:
Usar
Doublepara dinero.Mutar una lista mientras la recorres.
Ordenar antes de agrupar.
Olvidar el caso de
limit = 0.Formatear con la configuración local del sistema y obtener un resultado distinto en CI.
Definición de hecho: los 6 tests pasan y puedes explicar por qué List y MutableList son interfaces distintas.
Nivel 3 — Clases y dominio
Objetivo: modelar dinero, cuentas y transferencias con tipos que impidan errores.
| Ejercicio | Concepto | Pista |
|---|---|---|
Money.plus | operator fun | Suma céntimos |
Money.minus | operator fun | Resta céntimos |
Money.isPositive | Predicado | cents > 0 |
Account.available | Composición | balance - reserved |
validateTransfer | sealed interface + buildList | Acumula todos los motivos, no solo el primero |
Reglas de validateTransfer:
Importe mayor que cero.
IBAN con 2 letras, 2 dígitos y 11–30 caracteres alfanuméricos.
Referencia de 140 caracteres como máximo.
Importe menor o igual que el saldo disponible.
Errores típicos:
Validar con
ify devolver el primer error.Usar
Stringpara el estado en vez desealed.Mutar
Moneyen vez de devolver una copia.No normalizar el IBAN con
trim().uppercase().Confundir saldo con saldo disponible.
Definición de hecho: los 8 tests pasan y puedes dibujar el flujo de una transferencia válida e inválida.
Nivel 4 — Genéricos y sealed types
Objetivo: escribir APIs reutilizables sin perder tipos.
| Ejercicio | Concepto | Pista |
|---|---|---|
ApiResult.map | Genéricos + sealed | Success transforma; Failure se propaga |
ApiResult.getOrNull | Pattern matching | when sobre los dos casos |
Repository.findById | Interfaz genérica | items[id] |
Repository.findAll | Orden de inserción | linkedMapOf |
Repository.save | Reemplazo por id | items[idOf(entity)] = entity |
Repository.delete | Booleano | remove(id) != null |
Box.map | Covarianza | Box(transform(value)) |
median | Comparable<T> | Ordena y elige el índice central inferior |
Errores típicos:
Usar
Anyen vez de un parámetro de tipo.Hacer
ApiResultinvariante y pelear con el compilador.Implementar
findAlldevolviendo la referencia interna mutable.Confundir mediana inferior con media.
Usar
!!engetOrNull.
Definición de hecho: los 7 tests pasan y puedes explicar por qué ApiResult.Failure es ApiResult<Nothing>.
Nivel 5 — Kotlin avanzado
Objetivo: leer y escribir código Kotlin que no parece Dart traducido.
| Ejercicio | Concepto | Pista |
|---|---|---|
typeName | inline + reified | T::class.simpleName |
fibonacci | sequence + yield | Dos variables y un bucle infinito |
AccountBuilder.build | Builder | Devuelve Account(id, name, balanceCents) |
account | Lambda con receptor | AccountBuilder().apply(block).build() |
readOnlyList | Delegación by | ReadOnlyList(values.toList()) |
Errores típicos:
Usar
Listdonde hace faltaMutableListo al revés.Creer que
sequencees una lista; es perezosa.Usar
applycuandoletse lee mejor.Abusar de DSLs para un caso de dos líneas.
Olvidar que
reifiedsolo funciona en funcionesinline.
Definición de hecho: los 4 tests pasan y puedes explicar cuándo no usar un DSL.
Nivel 6 — Coroutines y Flow
Objetivo: pasar de Future/Stream a suspend/Flow con tests deterministas.
| Ejercicio | Concepto | Pista |
|---|---|---|
loadAccount | suspend + delay | Simula latencia y devuelve la cuenta |
retry | Reintentos | Guarda el último error y relanza al agotar |
accountUpdates | flow { emit(...) } | Tres emisiones |
searchAccounts | Flow.map | Filtra por nombre con ignoreCase = true |
Errores típicos:
Usar
Thread.sleepen una coroutine.Lanzar
GlobalScope.Capturar
CancellationExceptiony seguir.Testear con
delayreal en vez derunTest.Confundir
FlowconStateFlow.
Definición de hecho: los 6 tests pasan y puedes explicar la diferencia entre launch y async.
Nivel 7 — Idioms de mid Android
Objetivo: escribir Kotlin idiomático y entender qué sobrevive a process death. Acompaña a docs/ANDROID_FLUENCY.md §1.4, §3 y §4.
| Ejercicio | Concepto | Pista |
|---|---|---|
Iban.parse | value class + smart constructor | filterNot { it.isWhitespace() }, uppercase(), takeIf, ?.let(::Iban) |
Iban.masked | Extensión | take(4) y takeLast(4) |
normalizeReference | ?. + takeIf | trim(), isNotEmpty(), take(140) |
transferArgs | apply (≈ cascade .. de Dart) | Como Intent().apply { putExtra(…) } |
alsoLog | also | Efecto lateral y devuelve this |
CountingAccountRepository | Delegación de clase by | Solo sobrescribes accounts() |
ExchangeRates.rates | by lazy | Sustituye el getter por val rates by lazy(load) |
runSuspendCatching | runCatching + cancelación | catch (e: CancellationException) { throw e } antes del Throwable |
SavedStateHandle | operator get/set + StateFlow | flows.getOrPut(key) { … } |
saved | Delegado de propiedad | object : ReadWriteProperty<Any?, T>, clave = property.name |
canSubmit | Juntarlo todo | Iban.parse(...) != null y toLongOrNull() |
Errores típicos:
Usar
typealiasesperando seguridad de tipos:Centsacepta cualquierLong.Hacer público el constructor de
Ibany validar fuera: el tipo deja de garantizar nada.?.let { } ?: run { }como if/else.Usar
runCatchingen códigosuspend: el test de cancelación lo demuestra.Creer que el
ViewModelsobrevive a process death: solo sobrevive lo que está en elSavedStateHandle.
Extra: añade la validación real del IBAN (mod 97) a Iban.parse y escribe un test con un dígito de control incorrecto.
Definición de hecho: los 15 tests pasan y puedes explicar, sin mirar, por qué a cancelled coroutine does not keep running fallaría con runCatching.
Nivel 8 — ViewModel de una transferencia
Objetivo: implementar el ViewModel de una transferencia como en producción y entender, con tests, tres cosas que se preguntan siempre: qué pasa con viewModelScope al salir de la pantalla, qué sobrevive a process death y por qué la idempotency key evita el doble cobro. Lee antes docs/ANDROID_BASICS.md §5–§7 y §11.
| Ejercicio | Concepto | Pista |
|---|---|---|
ViewModel.clear | Ciclo de vida del ViewModel | viewModelScope.cancel() y luego onCleared() |
onAmountChange | SavedStateHandle | Copia onIbanChange |
onReview | Validación + idempotency key | current.idempotencyKey ?: newIdempotencyKey(); guárdala en savedState |
onEdit | Nueva intención de pago | savedState.remove(KEY_IDEMPOTENCY) |
onConfirm | Scope correcto + incertidumbre | appScope.launch, no viewModelScope; timeout → Unknown |
onRetry | Reintento idempotente | Es onConfirm() con la misma key |
Errores típicos:
Lanzar el envío en
viewModelScope: el testleaving the screen…falla porque la transferencia se cancela.Generar la key en
onConfirm: cada reintento sería una transferencia distinta.Tratar el timeout como
Failed: el usuario reintentaría sin saber que ya pagó.Guardar en
savedStatela lista de cuentas o el recibo entero: solo lo pequeño que genera el usuario.
Extra: tras process death con una key guardada y sin recibo, el estado debería empezar en Unknown y consultar al servidor por la key. Escribe el test primero.
Definición de hecho: los 12 tests pasan y puedes explicar en voz alta, sin mirar, el test retrying after a timeout reuses the key and the server pays only once. Compáralo con TransferViewModel.onConfirm() de Kontor: ¿qué cambiarías allí?
Después de los niveles
Cuando termines el nivel 8, no has terminado Kotlin. Has terminado la base.
Siguiente:
docs/PROJECTS.md: capstones.docs/KONTOR_TICKETS.md: Android/Compose real.docs/ASSESSMENT.md: rúbrica y banco único de preguntas.docs/CURRICULUM.md: fases 4–6.
Cómo saber que un nivel está realmente hecho
No es “los tests pasan”. Es:
los tests pasan;
puedes explicar cada función sin mirar;
puedes escribir una variante nueva sin copiar;
sabes qué errores evitaste;
puedes decir cuándo no usar ese concepto.
Si falla una de esas, repite el nivel con variaciones.