---
name: kotlin-experto
description: >
  Kotlin moderno: coroutines, Flow con stateIn/shareIn, ViewModel con
  SavedStateHandle, Hilt DI, Room con Flow, Ktor server, kotlinx.serialization
  y sealed classes para estados UI. Cargar cuando se implementen features
  Android/Kotlin, ViewModels, repositorios Room, o servicios backend Ktor.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para implementar pantallas Jetpack Compose — para Compose cargar `kotlin-compose`."
  - "No cargar para escribir tests Kotlin (MockK, Turbine, runTest) — para testing cargar `kotlin-testing`."
  - "No cargar para errores de compilación Kotlin o Gradle — para build errors cargar `build-errors-kotlin`."
  - "No cargar para servicios Java Spring Boot sin Kotlin — Java tiene su propio modelo de concurrencia; cargar `java-experto`."
evolvable: true  # default para skill estandar
---
# Kotlin Experto — Coroutines, Flow y Android

## Cuándo NO cargar

- La tarea es implementar pantallas Jetpack Compose (composables, state hoisting, Navigation Compose) — cargar `kotlin-compose`.
- La tarea es escribir tests Kotlin con MockK, Turbine o runTest — cargar `kotlin-testing`.
- Los errores son de compilación Kotlin, Hilt o Gradle — cargar `build-errors-kotlin`.
- El proyecto es Java Spring Boot sin Kotlin — Java tiene su propio modelo de concurrencia y DI; cargar `java-experto`.

## Coroutines — Scopes y Dispatchers

```kotlin
// Regla: usar el dispatcher correcto según el trabajo
class FacturasViewModel(
    private val repo: IFacturasRepository,
    savedState: SavedStateHandle
) : ViewModel() {

    // SavedStateHandle sobrevive a process death
    private val empresaId = savedState.get<String>("empresaId")
        ?: throw IllegalArgumentException("empresaId requerido")

    // StateFlow para UI state — nunca MutableStateFlow público
    private val _uiState = MutableStateFlow<FacturasUiState>(FacturasUiState.Cargando)
    val uiState: StateFlow<FacturasUiState> = _uiState.asStateFlow()

    init {
        cargarFacturas()
    }

    private fun cargarFacturas() {
        viewModelScope.launch {  // se cancela con el ViewModel
            _uiState.value = FacturasUiState.Cargando
            repo.observarFacturas(empresaId)
                .catch { e ->
                    _uiState.value = FacturasUiState.Error(e.message ?: "Error desconocido")
                }
                .collect { facturas ->
                    _uiState.value = FacturasUiState.Exito(facturas)
                }
        }
    }
}

// withContext para cambiar dispatcher
suspend fun cargarDesdeRed(): List<Factura> = withContext(Dispatchers.IO) {
    api.obtenerFacturas()   // IO: red, disco
}

// async/await para paralelismo
suspend fun cargarDashboard(): Dashboard = coroutineScope {
    val facturasDeferred = async { api.obtenerFacturas() }
    val clientesDeferred = async { api.obtenerClientes() }
    Dashboard(facturas = facturasDeferred.await(), clientes = clientesDeferred.await())
}
```

---

## Flow — stateIn, shareIn, conflate

```kotlin
class FacturasRepository(private val dao: FacturasDao) : IFacturasRepository {

    // stateIn: convierte Flow frío en StateFlow caliente
    // SharingStarted.WhileSubscribed(5000): mantiene 5s luego del último suscriptor
    // (sobrevive rotación de pantalla)
    override fun observarFacturas(empresaId: String): StateFlow<List<Factura>> =
        dao.observarPorEmpresa(empresaId)
            .map { entities -> entities.map { it.toDomain() } }
            .stateIn(
                scope = externalScope,          // scope del Application, no del ViewModel
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    // conflate: descarta intermedios cuando el consumidor es lento
    fun observarProgreso(): Flow<Int> = producirProgreso().conflate()
}

// Room DAO retorna Flow — se re-emite en cada cambio de BD
@Dao
interface FacturasDao {
    @Query("SELECT * FROM facturas WHERE empresa_id = :empresaId ORDER BY fecha DESC")
    fun observarPorEmpresa(empresaId: String): Flow<List<FacturaEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertar(factura: FacturaEntity)
}
```

---

## Hilt — Módulos e Inyección

```kotlin
// Módulo para dependencias externas / interfaces
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideHttpClient(): HttpClient = HttpClient(Android) {
        install(ContentNegotiation) { json() }
        install(HttpTimeout) { requestTimeoutMillis = 30_000 }
    }

    @Provides
    @Singleton
    fun provideFacturasApi(client: HttpClient): IFacturasApi =
        FacturasApiImpl(client)
}

// Bind: implementación de interfaz en el mismo módulo
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindFacturasRepo(
        impl: FacturasRepositoryImpl
    ): IFacturasRepository
}

// ViewModel con Hilt
@HiltViewModel
class FacturasViewModel @Inject constructor(
    private val repo: IFacturasRepository,
    savedState: SavedStateHandle
) : ViewModel()
```

---

## Sealed Classes para Estados UI

```kotlin
sealed interface FacturasUiState {
    data object Cargando : FacturasUiState
    data class Exito(val facturas: List<Factura>) : FacturasUiState
    data class Error(val mensaje: String) : FacturasUiState
}

// Uso exhaustivo en Compose — el compilador fuerza todos los casos
@Composable
fun FacturasScreen(uiState: FacturasUiState) {
    when (uiState) {
        is FacturasUiState.Cargando -> CircularProgressIndicator()
        is FacturasUiState.Error -> ErrorView(uiState.mensaje)
        is FacturasUiState.Exito -> FacturasList(uiState.facturas)
    }
}
```

---

## Ktor — Routing y Plugins

```kotlin
fun Application.configurarRouting() {
    install(ContentNegotiation) { json() }
    install(RequestValidation) {
        validate<CrearFacturaRequest> { req ->
            if (req.folio.isBlank()) ValidationResult.Invalid("folio requerido")
            else ValidationResult.Valid
        }
    }
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respond(HttpStatusCode.InternalServerError,
                mapOf("error" to (cause.message ?: "Error interno")))
        }
    }

    routing {
        route("/api/v1") {
            authenticate("jwt") {
                facturaRoutes()
            }
        }
    }
}

fun Route.facturaRoutes() {
    route("/facturas") {
        get {
            val empresaId = call.principal<JWTPrincipal>()!!
                .payload.getClaim("empresa_id").asString()
            val facturas = facturasService.obtenerTodas(empresaId)
            call.respond(facturas)
        }
        post {
            val req = call.receive<CrearFacturaRequest>()
            val factura = facturasService.crear(req)
            call.respond(HttpStatusCode.Created, factura)
        }
    }
}
```

---

## kotlinx.serialization

```kotlin
@Serializable
data class FacturaDto(
    val id: String,
    val folio: String,
    val total: Double,
    @SerialName("fecha_emision") val fechaEmision: String,
    val estatus: EstatusFactura = EstatusFactura.BORRADOR
)

@Serializable
enum class EstatusFactura { BORRADOR, EMITIDA, CANCELADA }

// Configuración global de JSON
val jsonConfig = Json {
    ignoreUnknownKeys = true    // tolerante a campos extras de API
    isLenient = false
    encodeDefaults = false       // no serializar campos con valor default
    prettyPrint = false
}
```

---

## Extension Functions útiles

```kotlin
// Result como alternativa a excepciones
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
    try { Result.success(block()) }
    catch (e: CancellationException) { throw e }   // NUNCA atrapar CancellationException
    catch (e: Exception) { Result.failure(e) }

// Flow de paginación
fun <T> Flow<List<T>>.paginar(pagina: Int, tamanoPagina: Int): Flow<List<T>> =
    map { it.drop(pagina * tamanoPagina).take(tamanoPagina) }
```

---

## Anti-patrones

### GlobalScope — NUNCA

```kotlin
// MAL — no tiene lifecycle, las coroutines se filtran
GlobalScope.launch { api.obtenerFacturas() }

// BIEN — scope ligado al lifecycle
viewModelScope.launch { ... }    // ViewModel
lifecycleScope.launch { ... }    // Activity/Fragment
```

### Atrapar CancellationException — NUNCA

```kotlin
// MAL — rompe la cooperación de coroutines
try { ... } catch (e: Exception) { /* atrapa CancellationException */ }

// BIEN — re-lanzar siempre
try { ... } catch (e: Exception) {
    if (e is CancellationException) throw e
    // manejar otros errores
}
```

### MutableStateFlow público — NUNCA

```kotlin
// MAL — UI puede modificar el estado desde afuera
val uiState = MutableStateFlow<Estado>(Estado.Cargando)

// BIEN — privado con backing property
private val _uiState = MutableStateFlow<Estado>(Estado.Cargando)
val uiState: StateFlow<Estado> = _uiState.asStateFlow()
```

---

## Gotchas / Errores comunes no obvios

**`CancellationException` atrapada en un `catch(e: Exception)` rompe la cooperación de coroutines**: un bloque `catch (e: Exception) { /* ignorar */ }` captura `CancellationException` porque es subclase de `Exception`, lo que impide que la coroutine cancele correctamente. La coroutine sigue ejecutando después de que su scope fue cancelado. Fix: siempre hacer `catch (e: CancellationException) { throw e }` o usar `catch (e: Exception) { if (e is CancellationException) throw e }`.

**`stateIn(scope, SharingStarted.Eagerly, ...)` inicia el Flow aunque no haya suscriptores y consume recursos en background**: usar `Eagerly` en un ViewModel que se crea pero no se observa inmediatamente mantiene el Flow activo y puede generar requests innecesarios a la BD. Fix: usar `SharingStarted.WhileSubscribed(5000)` para activar el Flow solo cuando hay colectores activos, con 5 segundos de gracia para rotaciones de pantalla.

**`viewModelScope.launch` sobrevive a la destrucción del ViewModel si usa `withContext(Dispatchers.IO)`**: el coroutine scope del ViewModel se cancela en `onCleared()`, pero si hay trabajo en `Dispatchers.IO` en progreso, ese trabajo puede seguir ejecutando hasta que termine porque `Dispatchers.IO` no respeta automáticamente la cancelación del scope si el código no verifica `isActive`. Fix: asegurar que el código I/O usa operaciones cancelables o verifica `coroutineContext.isActive` periódicamente en loops.

**`@HiltViewModel` con parámetros que no son `SavedStateHandle` rompe la inyección en runtime**: si se agrega un parámetro no provisto por Hilt al constructor del ViewModel (como un ID de entidad), Hilt no puede crear el ViewModel y lanza `RuntimeException` al navegar a la pantalla. Fix: pasar datos iniciales siempre a través de `SavedStateHandle` desde los argumentos de navegación, nunca como constructor directo del ViewModel.

## Checklist

- [ ] No hay GlobalScope.launch en código de producción
- [ ] CancellationException siempre se re-lanza
- [ ] MutableStateFlow siempre privado con backing property
- [ ] Room DAO retorna Flow para observabilidad reactiva
- [ ] stateIn usa WhileSubscribed(5000) para sobrevivir rotaciones
- [ ] Hilt: repositorios en SingletonComponent, ViewModels con @HiltViewModel
