K MP

NutriTrack — Compose Multiplatform Patterns

Implementación completa de FoodLogScreen con todos los patrones aplicados

Compose Multiplatform Kotlin 2.0 Material 3 KMP
Targets
Android
iOS
Desktop (Mac/Win)
Web (WASM)
1

FoodLogState.kt — Estado único de pantalla

commonMain
ui/foodlog/FoodLogState.kt Kotlin
package 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
}
2

FoodLogViewModel.kt — ViewModel + StateFlow

commonMain
ui/foodlog/FoodLogViewModel.kt Kotlin
package 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
            )}
        }
    }
}
3

FoodLogScreen.kt — Composables optimizados

commonMain
ui/foodlog/FoodLogScreen.kt Kotlin
package 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)
        }
    }
}
4

AppNavHost.kt — Navegación type-safe + Dialog

commonMain
navigation/AppNavHost.kt Kotlin
package 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() }
            )
        }
    }
}
5

AppTheme.kt + expect/actual nativo

commonMain · androidMain · iosMain
theme/AppTheme.kt Kotlin
// ── 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

✗ MAL — NavController como parámetro profundo
// 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") }
}
✓ BIEN — Lambda callback en su lugar
// Desacoplado: el composable solo expone intención
@Composable
fun FoodItemCard(
    item: FoodItem,
    onViewDetail: () -> Unit  // ← BIEN
) {
    Button(onClick = onViewDetail) {
        Text("Ver detalle")
    }
}
✗ MAL — Cómputo pesado en composable
// Se ejecuta en CADA recomposición
@Composable
fun MacroSummary(items: List<FoodItem>) {
    val total = items.sumOf { it.kcal }  // ← MAL
    Text("Total: $total kcal")
}
✓ BIEN — remember { } o propiedad del estado
// 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.