FoodLogState.kt — Estado único de pantalla
commonMainpackage com.nutritrack.ui.foodlog import androidx.compose.runtime.Immutable // Modelo de dominio — @Immutable habilita skip en recomposición @Immutable data class FoodItem( val id: String, val name: String, val kcal: Int, val protein: Float, // g val carbs: Float, // g val fat: Float // g ) // Estado único de la pantalla — un solo objeto, nunca múltiples LiveData @Immutable data class FoodLogState( val items: List<FoodItem> = emptyList(), val goalKcal: Int = 2000, val isLoading: Boolean = false, val error: String? = null, val pendingDeleteId: String? = null // controla diálogo de confirmación ) { // Propiedades derivadas — calculadas sin recomposición extra val totalKcal: Int get() = items.sumOf { it.kcal } val progress: Float get() = (totalKcal.toFloat() / goalKcal).coerceIn(0f, 1f) } // Event Sink — sealed interface en lugar de múltiples lambdas callback sealed interface FoodLogEvent { data object Refresh : FoodLogEvent data class RequestDelete(val itemId: String) : FoodLogEvent data object ConfirmDelete : FoodLogEvent data object CancelDelete : FoodLogEvent data object NavigateToAddFood : FoodLogEvent }
FoodLogViewModel.kt — ViewModel + StateFlow
commonMainpackage com.nutritrack.ui.foodlog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class FoodLogViewModel( private val getFoodLog: GetFoodLogUseCase, private val deleteFoodItem: DeleteFoodItemUseCase ) : ViewModel() { // Estado privado mutable — expuesto como StateFlow inmutable private val _state = MutableStateFlow(FoodLogState()) val state: StateFlow<FoodLogState> = _state.asStateFlow() init { loadLog() } // Single entry-point — composable solo llama onEvent() fun onEvent(event: FoodLogEvent) { when (event) { FoodLogEvent.Refresh -> loadLog() is FoodLogEvent.RequestDelete -> _state.update { it.copy(pendingDeleteId = event.itemId) } FoodLogEvent.ConfirmDelete -> confirmDelete() FoodLogEvent.CancelDelete -> _state.update { it.copy(pendingDeleteId = null) } FoodLogEvent.NavigateToAddFood -> { /* manejado en el NavHost via callback */ } } } private fun loadLog() { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } getFoodLog().fold( onSuccess = { items -> _state.update { it.copy(items = items, isLoading = false) } }, onFailure = { e -> _state.update { it.copy(error = e.message, isLoading = false) } } ) } } private fun confirmDelete() { val id = _state.value.pendingDeleteId ?: return viewModelScope.launch { deleteFoodItem(id) _state.update { it.copy( items = it.items.filterNot { item -> item.id == id }, pendingDeleteId = null )} } } }
FoodLogScreen.kt — Composables optimizados
commonMainpackage com.nutritrack.ui.foodlog import androidx.compose.foundation.lazy.items import androidx.compose.runtime.* // Stateful screen — solo recoge el estado y delega a composable stateless @Composable fun FoodLogScreen( onNavigateToAddFood: () -> Unit, viewModel: FoodLogViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() FoodLogContent( state = state, onEvent = { event -> if (event == FoodLogEvent.NavigateToAddFood) onNavigateToAddFood() else viewModel.onEvent(event) } ) } // Stateless content — fácil de previsualizar y testear sin ViewModel @Composable private fun FoodLogContent( state: FoodLogState, onEvent: (FoodLogEvent) -> Unit ) { // derivedStateOf: solo se recalcula cuando superamos 80% del goal val nearGoal by remember(state.progress) { derivedStateOf { state.progress >= 0.8f } } Scaffold( topBar = { FoodLogTopBar(onRefresh = { onEvent(FoodLogEvent.Refresh) }) }, floatingActionButton = { FloatingActionButton(onClick = { onEvent(FoodLogEvent.NavigateToAddFood) }) { Icon(Icons.Default.Add, contentDescription = "Añadir alimento") } } ) { padding -> Column(modifier = Modifier.padding(padding)) { CalorieProgressBar(state = state, highlighted = nearGoal) if (state.isLoading) { CircularProgressIndicator() } LazyColumn { // key() estable — permite reutilización de items y animaciones items(state.items, key = { it.id }) { item -> FoodItemCard( item = item, onDelete = { onEvent(FoodLogEvent.RequestDelete(item.id)) } ) } } } } } // Slot-based API — header, content y actions son slots intercambiables @Composable fun FoodItemCard( item: FoodItem, onDelete: () -> Unit, modifier: Modifier = Modifier ) { AppCard( modifier = modifier, header = { Text(item.name, style = MaterialTheme.typography.titleMedium) }, content = { MacroRow(kcal = item.kcal, protein = item.protein, carbs = item.carbs, fat = item.fat) }, actions = { TextButton(onClick = onDelete) { Text("Eliminar") } } ) } // Composable genérico reutilizable — slot-based API @Composable fun AppCard( modifier: Modifier = Modifier, header: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit, actions: @Composable RowScope.() -> Unit = {} ) { Card( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp) ) { Column(modifier = Modifier.padding(16.dp)) { header() Spacer(Modifier.height(8.dp)) Column(content = content) Row(horizontalArrangement = Arrangement.End, content = actions) } } }
AppNavHost.kt — Navegación type-safe + Dialog
commonMainpackage com.nutritrack.navigation import kotlinx.serialization.Serializable // Rutas type-safe — @Serializable elimina strings mágicos @Serializable data object FoodLogRoute @Serializable data object FoodSearchRoute @Serializable data class FoodDetailRoute(val itemId: String) @Serializable data class ConfirmDeleteRoute(val itemId: String) @Composable fun AppNavHost( navController: NavHostController = rememberNavController() ) { NavHost(navController, startDestination = FoodLogRoute) { composable<FoodLogRoute> { FoodLogScreen( onNavigateToAddFood = { navController.navigate(FoodSearchRoute) } ) } composable<FoodSearchRoute> { FoodSearchScreen( onFoodSelected = { id -> navController.navigate(FoodDetailRoute(id)) } ) } composable<FoodDetailRoute> { backStackEntry -> val route = backStackEntry.toRoute<FoodDetailRoute>() FoodDetailScreen(itemId = route.itemId) } // Diálogo de confirmación vía dialog() — sin show/hide imperativo dialog<ConfirmDeleteRoute> { backStackEntry -> val route = backStackEntry.toRoute<ConfirmDeleteRoute>() ConfirmDeleteDialog( itemId = route.itemId, onConfirm = { /* dispatch event → ViewModel */ navController.popBackStack() }, onDismiss = { navController.popBackStack() } ) } } }
AppTheme.kt + expect/actual nativo
commonMain · androidMain · iosMain// ── commonMain ────────────────────────────────────────────────────────────── @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { // Colores de NutriTrack: verde nutrición + morado energía val lightColors = lightColorScheme( primary = Color(0xFF2E7D32), // verde secondary = Color(0xFF7B1FA2), // morado tertiary = Color(0xFFE65100) // naranja (calorías altas) ) val darkColors = darkColorScheme( primary = Color(0xFF66BB6A), secondary = Color(0xFFCE93D8), tertiary = Color(0xFFFF8A65) ) NutritrackStatusBar(darkIcons = !darkTheme) // expect/actual por plataforma MaterialTheme( colorScheme = if (darkTheme) darkColors else lightColors, typography = NutritrackTypography, content = content ) } // expect en commonMain — cada plataforma provee su actual @Composable expect fun NutritrackStatusBar(darkIcons: Boolean) // ── androidMain ───────────────────────────────────────────────────────────── @Composable actual fun NutritrackStatusBar(darkIcons: Boolean) { val systemUiController = rememberSystemUiController() SideEffect { systemUiController.setStatusBarColor(Color.Transparent, darkIcons) } } // ── iosMain ────────────────────────────────────────────────────────────────── @Composable actual fun NutritrackStatusBar(darkIcons: Boolean) { // iOS gestiona la barra de estado vía UIKit / Info.plist UIStatusBarStyle // No-op: Compose no toca UIKit directamente para la status bar } // ── desktopMain ────────────────────────────────────────────────────────────── @Composable actual fun NutritrackStatusBar(darkIcons: Boolean) { // Escritorio no tiene barra de estado nativa — no-op }
Anti-patrones evitados en NutriTrack
// Acoplamiento: composables internos conocen la navegación @Composable fun FoodItemCard( item: FoodItem, navController: NavHostController // ← MAL ) { Button(onClick = { navController.navigate(FoodDetailRoute(item.id)) }) { Text("Ver detalle") } }
// Desacoplado: el composable solo expone intención @Composable fun FoodItemCard( item: FoodItem, onViewDetail: () -> Unit // ← BIEN ) { Button(onClick = onViewDetail) { Text("Ver detalle") } }
// Se ejecuta en CADA recomposición @Composable fun MacroSummary(items: List<FoodItem>) { val total = items.sumOf { it.kcal } // ← MAL Text("Total: $total kcal") }
// Solo recalcula cuando cambia items @Composable fun MacroSummary(items: List<FoodItem>) { val total = remember(items) { items.sumOf { it.kcal } // ← BIEN } Text("Total: $total kcal") }
Resumen de patrones aplicados
🔷 Estado único por pantalla
FoodLogState como un solo data class @Immutable. Expuesto como StateFlow y recolectado con collectAsStateWithLifecycle para respetar el ciclo de vida.
⚡ Event Sink centralizado
sealed interface FoodLogEvent unifica Refresh, RequestDelete, ConfirmDelete, Cancel y NavigateToAddFood. El composable llama solo a onEvent().
🧭 Navegación type-safe
Rutas @Serializable eliminan strings mágicos. backStackEntry.toRoute() extrae parámetros tipados. dialog() gestiona el diálogo de confirmación de forma declarativa.
🎨 Slot-based composables
AppCard con slots header, content y actions. Cada slot es un lambda @Composable, máxima flexibilidad sin romper la API del componente.
⚙️ Optimización de recomposición
@Immutable en modelos UI, key() estable en LazyColumn, derivedStateOf para nearGoal y remember(items) para cómputos derivados.
📱 expect/actual multiplataforma
NutritrackStatusBar tiene implementación real en Android (SystemUiController) y no-op en iOS y Desktop. Código compartido sin condicionales de plataforma.