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.
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)
}
}
// 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
FileStorageProvider en domain, impleméntala en el módulo data/androidMain con acceso a Context vía Hilt, e inyéctala en el repositorio.
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.
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")
}
}
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")
}
}
CancellationException que relance la excepción. Considera usar runCatching { }.onFailure { if (it is CancellationException) throw it } como helper reutilizable en el proyecto.
.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.
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
}
private val _logs = MutableStateFlow(listOf<HealthEvent>())
fun addEvent(event: HealthEvent) {
_logs.update { current -> current + event }
// nueva referencia de lista → Compose recompone correctamente
}
StateFlow<List<T>> (inmutable) y .update { it + newItem } o it.copy(...) para emitir siempre un nuevo objeto. Nunca expongas MutableList en StateFlow.
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.
@Composable
fun HealthLogScreen(
navController: NavController, // ← dependencia directa
viewModel: HealthLogViewModel = hiltViewModel()
) {
Button(onClick = { navController.navigate("petDetail/$petId") }) {
Text("Ver mascota")
}
}
@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
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.
!! 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
!! por ?. con fallback o por requireNotNull/checkNotNull con mensaje descriptivo. Reserva !! únicamente cuando null sea un error de programación imposible por diseño.
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()
)
.stateIn(WhileSubscribed(5000)). Los 5 segundos de gracia evitan restart innecesario en rotaciones de pantalla.
"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))
composeResources/values/strings.xml y accede con stringResource(). Para KMP, usa la API de Compose Multiplatform resources.
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)
}
}
key = { it.id } al items(). Asegúrate de que HealthEvent.id es un identificador estable y único.
libs.versions.toml que ya usa el resto del proyecto.
gradle/libs.versions.toml y referencia con libs.ktor.client.core, etc. Mantiene las versiones centralizadas y facilita actualizaciones con Renovate/Dependabot.
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.
PetRepositoryImpl a androidMain. Declara la interfaz PetRepository en commonMain. Para iOS, crea una implementación con Core Data o SQLDelight en iosMain.
| 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 |
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.