Kotlin PR #47 KMP + Compose BLOCK — No mergear

Revisión de Código — PetTrack App

🔍 Feature: Health Log Screen + Pet Repository refactor 📁 5 ficheros revisados ⚡ Kotlin 2.0 · Room · Hilt · Ktor · Coroutines 📅 PR #47 → main (v2.3.0)
1
Critical
BLOCK
3
High
BLOCK
4
Medium
INFO
2
Low
NOTE
Ficheros revisados
domain/…/LogHealthEventUseCase.kt
presentation/…/HealthLogViewModel.kt
presentation/…/HealthLogScreen.kt
data/…/PetRepositoryImpl.kt
build.gradle.kts
Critical
CRITICAL Domain module importa clase Android Context Architecture
domain/src/main/kotlin/com/pettrack/domain/usecase/LogHealthEventUseCase.kt:8
La clase LogHealthEventUseCase importa android.content.Context para resolver la carpeta de archivos donde guardar los logs de salud. El módulo domain debe ser Kotlin puro, sin dependencias del framework Android, Ktor, Room ni ninguna plataforma. Esta violación impide reutilizar el dominio en iOS/desktop bajo KMP.
✗ Mal
import android.content.Context  // ← viola clean architecture

class LogHealthEventUseCase(
    private val context: Context,
    private val repository: HealthRepository
) {
    suspend operator fun invoke(event: HealthEvent) {
        val dir = context.filesDir  // lógica de plataforma en dominio
        repository.save(event, dir.path)
    }
}
✓ Correcto
// domain — puro Kotlin, sin Android
class LogHealthEventUseCase(
    private val repository: HealthRepository  // interfaz domain
) {
    suspend operator fun invoke(event: HealthEvent) {
        repository.save(event)  // el repositorio resuelve la ruta
    }
}

// En data/android: PetRepositoryImpl inyecta Context y resuelve filesDir
Solución: Elimina Context del use case. Si necesitas la ruta de almacenamiento, extrae una interfaz FileStorageProvider en domain, impleméntala en el módulo data/androidMain con acceso a Context vía Hilt, e inyéctala en el repositorio.
High
HIGH CancellationException silenciada en ViewModel Coroutines
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogViewModel.kt:34
El bloque catch (e: Exception) captura también CancellationException. Cuando el scope se cancela (p.ej. el usuario cierra la pantalla), la cancelación queda silenciada y la corrutina sigue corriendo, provocando work leaks y potencial crash por acceso a ViewModel destruido.
✗ Mal
viewModelScope.launch {
    try {
        val result = healthRepository.fetchLogs(petId)
        _uiState.value = UiState.Success(result)
    } catch (e: Exception) {  // ← atrapa CancellationException
        _uiState.value = UiState.Error(e.message ?: "Error")
    }
}
✓ Correcto
viewModelScope.launch {
    try {
        val result = healthRepository.fetchLogs(petId)
        _uiState.value = UiState.Success(result)
    } catch (e: CancellationException) {
        throw e  // preserva la cancelación estructurada
    } catch (e: Exception) {
        _uiState.value = UiState.Error(e.message ?: "Error")
    }
}
Solución: Añade siempre un catch específico para CancellationException que relance la excepción. Considera usar runCatching { }.onFailure { if (it is CancellationException) throw it } como helper reutilizable en el proyecto.
HIGH StateFlow con lista mutable — Compose no detecta cambios Coroutines / Compose
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogViewModel.kt:61
El ViewModel muta directamente la lista dentro del valor del StateFlow usando .add(). Compose compara la referencia del objeto: al mutar la misma instancia de lista, la referencia no cambia y la UI no se recompone. Los nuevos eventos de salud añadidos no aparecerán en pantalla.
✗ Mal
private val _logs = MutableStateFlow(mutableListOf<HealthEvent>())

fun addEvent(event: HealthEvent) {
    _logs.value.add(event)  // ← muta sin emitir nuevo valor
    // Compose NO detecta el cambio, la referencia es la misma
}
✓ Correcto
private val _logs = MutableStateFlow(listOf<HealthEvent>())

fun addEvent(event: HealthEvent) {
    _logs.update { current -> current + event }
    // nueva referencia de lista → Compose recompone correctamente
}
Solución: Usa StateFlow<List<T>> (inmutable) y .update { it + newItem } o it.copy(...) para emitir siempre un nuevo objeto. Nunca expongas MutableList en StateFlow.
HIGH NavController pasado como parámetro a Composable hoja Compose
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogScreen.kt:22
HealthLogScreen recibe NavController directamente como parámetro. Esto crea acoplamiento fuerte al sistema de navegación, dificulta los tests unitarios del Composable y puede provocar referencias stale si el NavController se recrea.
✗ Mal
@Composable
fun HealthLogScreen(
    navController: NavController,  // ← dependencia directa
    viewModel: HealthLogViewModel = hiltViewModel()
) {
    Button(onClick = { navController.navigate("petDetail/$petId") }) {
        Text("Ver mascota")
    }
}
✓ Correcto
@Composable
fun HealthLogScreen(
    onNavigateToPet: (petId: String) -> Unit,  // ← lambda estable
    viewModel: HealthLogViewModel = hiltViewModel()
) {
    Button(onClick = { onNavigateToPet(petId) }) {
        Text("Ver mascota")
    }
}
// El NavController vive SOLO en el NavGraph/Host
Solución: Sustituye NavController por lambdas tipadas onNavigateTo*(). El NavController debe existir únicamente en el nivel del NavHost o del grafo de navegación, jamás en Composables hoja.
Medium
MEDIUM Uso de !! (non-null assertion) en lógica de UI Kotlin Idioms
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogScreen.kt:87
Se usa el operador !! para desreferenciar el pet actual del estado. Si el estado llega a ser null (p.ej. durante la carga inicial), lanzará un NullPointerException no controlado, produciendo un crash silencioso.
// Mal
val petName = uiState.currentPet!!.name  // crash potencial

// Bien
val petName = uiState.currentPet?.name ?: "Mascota desconocida"
// o con requireNotNull si null es un estado inválido
val petName = requireNotNull(uiState.currentPet) { "currentPet no puede ser null aquí" }.name
Solución: Reemplaza !! por ?. con fallback o por requireNotNull/checkNotNull con mensaje descriptivo. Reserva !! únicamente cuando null sea un error de programación imposible por diseño.
MEDIUM Flow recolectado en init{} sin stateIn Coroutines
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogViewModel.kt:18
El Flow del repositorio se recolecta directamente en init {} con viewModelScope.launch { .collect() }. Cuando no hay suscriptores activos (UI en background), el upstream sigue activo y consume recursos innecesariamente.
// Problema
init {
    viewModelScope.launch {
        healthRepository.observeLogs(petId).collect { logs ->
            _uiState.update { it.copy(logs = logs) }
        }
    }
}

// Mejor — se detiene cuando no hay suscriptores
val uiState: StateFlow<HealthUiState> = healthRepository
    .observeLogs(petId)
    .map { logs -> HealthUiState(logs = logs) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = HealthUiState()
    )
Solución: Convierte el Flow a StateFlow con .stateIn(WhileSubscribed(5000)). Los 5 segundos de gracia evitan restart innecesario en rotaciones de pantalla.
MEDIUM Strings de usuario hardcodeadas en Composable Android Specific
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogScreen.kt:45,62,78
Tres strings visibles al usuario están hardcodeadas en el Composable ("Registrar evento", "Sin eventos de salud", "Error al cargar"). Imposible internacionalizar ni modificar desde resources.
// Mal
Text("Registrar evento de salud")  // hardcoded

// Bien (Compose Multiplatform resources)
Text(stringResource(Res.string.health_log_register_event))
Solución: Mueve todas las strings visibles a composeResources/values/strings.xml y accede con stringResource(). Para KMP, usa la API de Compose Multiplatform resources.
MEDIUM LazyColumn sin key() estable — rendimiento degradado Compose
presentation/src/main/kotlin/com/pettrack/ui/health/HealthLogScreen.kt:110
El LazyColumn de la lista de eventos de salud usa el índice implícito como key. Al insertar/eliminar ítems, Compose recrea todos los ítems en lugar de reusar los existentes, causando janks visibles y animaciones rotas.
// Mal — usa índice por defecto
LazyColumn {
    items(healthLogs) { event ->
        HealthEventItem(event = event)
    }
}

// Bien — key estable por ID del dominio
LazyColumn {
    items(healthLogs, key = { it.id }) { event ->
        HealthEventItem(event = event)
    }
}
Solución: Añade key = { it.id } al items(). Asegúrate de que HealthEvent.id es un identificador estable y único.
Low
LOW Versiones de dependencias hardcodeadas en build.gradle.kts Gradle & Build
build.gradle.kts:14–18
Cuatro dependencias nuevas del módulo (Ktor 2.3.11, Room 2.6.1, serialization 1.7.0) tienen las versiones incrustadas directamente en el DSL, fuera del catálogo libs.versions.toml que ya usa el resto del proyecto.
Solución: Añade las versiones en gradle/libs.versions.toml y referencia con libs.ktor.client.core, etc. Mantiene las versiones centralizadas y facilita actualizaciones con Renovate/Dependabot.
LOW Código Android-specific en commonMain en lugar de androidMain Gradle & Build / KMP
data/src/commonMain/kotlin/com/pettrack/data/repository/PetRepositoryImpl.kt:5
El repositorio en commonMain importa androidx.room.* directamente. Room es una librería Android-only; en la estructura KMP este fichero debería estar en androidMain, con una interfaz shared en commonMain.
Solución: Mueve PetRepositoryImpl a androidMain. Declara la interfaz PetRepository en commonMain. Para iOS, crea una implementación con Core Data o SQLDelight en iosMain.
📊 Resumen de la Revisión
Severidad Categoría Count Estado
CRITICAL Architecture 1 BLOCK
HIGH Coroutines · Compose 3 BLOCK
MEDIUM Idioms · Android · Compose 4 INFO
LOW Gradle · KMP 2 NOTE
🚫

VEREDICTO: BLOCK — No mergear a main

Existen 1 issue CRITICAL (violación de Clean Architecture que rompe compatibilidad KMP con iOS) y 3 issues HIGH (bug de cancelación en corrutinas, StateFlow mutable, NavController acoplado) que deben corregirse antes del merge. Los 4 issues MEDIUM se recomienda resolver en el mismo PR. Los 2 LOW pueden ir en un PR de refactor posterior.