App «Pedidos»: desarrollo paso a paso

El taller práctico de la serie: construye la aplicación guía completa, siguiendo el orden real de un proyecto — datos, interfaz, servidor, hardware y lanzamiento. Cada paso tiene código exacto para pegar, checkpoints verificables y referencias a la teoría de los manuales anteriores.

17 sprints Kotlin 2.x Jetpack Compose Proyecto real De cero a Google Play
17
Sprints secuenciales
100%
Código completo por paso
5
Fases de desarrollo
1
App publicada al final
Cómo usar este taller: avanza en orden: cada sprint compila sobre el anterior. Al final de cada uno encontrarás un recuadro Checkpoint (qué debe hacer la aplicación ya) y otro de Teoría de apoyo con el capítulo exacto de los manuales 01–04. Si algo no funciona, vuelve al último checkpoint verde y compara tu código.

1 · Preparar el terreno

Sprint 1 ~15 min

Objetivo del sprint: proyecto «Pedidos» creado, limpio y corriendo en el emulador. Sin funciones todavía — solo la base sobre la que crecerá todo.

Paso 1 · Crear el proyecto

  • Android Studio → New Project → plantilla Empty Activity (la de Compose).
  • Name: Pedidos · Package: com.pedidos.app.
  • Minimum SDK: API 26 (Android 8.0) — cubre ~97 % de dispositivos.
  • Build configuration language: Kotlin DSL (viene por defecto).

Paso 2 · Estructura de paquetes

Crea ahora las carpetas lógicas (paquetes) que usaremos todo el taller; así cada sprint sabe dónde vive:

app/src/main/java/com/pedidos/app/
├── MainActivity.kt        // ya existe
├── datos/                 // Sprint 3-4: Room y repositorio
├── logica/                // Sprint 6+: ViewModels
└── ui/
    ├── theme/             // ya existe (Color, Theme, Type)
    ├── listado/           // Sprint 5+
    └── detalle/           // Sprint 8+

Paso 3 · Dependencias base

En app/build.gradle.kts, dentro de dependencies:

// El BOM ya viene en la plantilla; verifica estas líneas:
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.9.3")

Sincroniza con Sync Now. Las demás dependencias (Room, Retrofit, cámara…) se añaden en su sprint correspondiente — igual que en un proyecto real: solo cuando hacen falta.

Paso 4 · Primera ejecución

Crea el emulador (Device Manager → Create Virtual Device → Pixel 8, API 35) y deja MainActivity con lo mínimo:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PedidosTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Text(
                        "Pedidos · sprint 1",
                        modifier = Modifier.padding(24.dp),
                        style = MaterialTheme.typography.headlineMedium
                    )
                }
            }
        }
    }
}
Teoría de apoyo: qué es setContent y el BOM → Manual 04 · Capítulo 2. Anatomía de la plantilla generada → Manual 03 · Capítulo 1.
Checkpoint: al pulsar Run, el emulador muestra «Pedidos · sprint 1» con tipografía grande. Rota el emulador (Ctrl+Izq/Der): nada debe romperse.

2 · El modelo de datos

Sprint 2 ~15 min

Objetivo: definir qué ES un pedido en «Pedidos». Antes de pantallas o servidores: el dominio. Como buen backend, empiezas por el modelo.

Paso 1 · El ciclo de vida de un pedido

Un pedido nace pendiente, se prepara, sale a reparto y se entrega (o se cancela). Modelamos eso como enum sellado — la máquina de estados vive aquí:

// logica/EstadoPedido.kt
package com.pedidos.app.logica

enum class EstadoPedido {
    PENDIENTE, EN_PREPARACION, EN_CAMINO, ENTREGADO, CANCELADO;

    val esFinal: Boolean
        get() = this == ENTREGADO || this == CANCELADO
}

Paso 2 · La entidad de negocio

// datos/Pedido.kt
package com.pedidos.app.datos

data class Pedido(
    val id: Long = 0,                 // 0 = aún sin guardar
    val cliente: String,
    val direccion: String,
    val detalle: String,
    val total: Double,
    val estado: EstadoPedido = EstadoPedido.PENDIENTE,
    val creadoEn: Long = System.currentTimeMillis(),
    val fotoEvidencia: String? = null // Sprint 13
)

Decisiones conscientes (anótalas en el README del proyecto):

  • id = 0 como centinela de «nuevo»: Room autogenerará el real.
  • Fechas como Long (epoch millis): trivial de ordenar y persistir.
  • Campo fotoEvidencia desde ya: evita migraciones en el sprint 13.

Paso 3 · Datos de prueba

Para no depender del servidor todavía, un generador de semilla:

// datos/DatosPrueba.kt
package com.pedidos.app.datos

object DatosPrueba {
    fun pedidos() = listOf(
        Pedido(cliente = "Rosa Quispe", direccion = "Av. Larco 542",
               detalle = "1 pizza familiar", total = 89.90,
               estado = EstadoPedido.EN_CAMINO),
        Pedido(cliente = "Julio Vargas", direccion = "Jr. Unión 120",
               detalle = "2 menús", total = 32.00),
        Pedido(cliente = "Ana Torres", direccion = "Ca. Los Cedros 789",
               detalle = "Postres surtidos", total = 45.50,
               estado = EstadoPedido.ENTREGADO)
    )
}
Teoría de apoyo: data classes e invariantes → Manual 01. DTO vs entidad de dominio → Glosario · Categoría «Datos».
Checkpoint: el proyecto compila sin cambios visibles en pantalla. Verifica en tu mente (o con un temporal Log.d) que DatosPrueba.pedidos().size == 3.

3 · Persistencia con Room

Sprint 3 ~20 min

Objetivo: los pedidos sobreviven al cierre de la aplicación. Room sobre SQLite, con consultas observables que la interfaz consumirá en el sprint 7.

Paso 1 · Dependencias y procesador

// app/build.gradle.kts — plugins block del módulo:
id("com.google.devtools.ksp")

// raíz build.gradle.kts:
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false

// dependencies:
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
La versión de KSP debe corresponder a TU versión de Kotlin (kotlin -version). Si Sync falla, ese es el motivo número uno.

Paso 2 · Entidad de tabla + conversor de enum

La tabla no guarda enums directamente: un conversor los traduce a texto.

// datos/PedidoEntidad.kt
package com.pedidos.app.datos

@Entity(tableName = "pedidos")
data class PedidoEntidad(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val cliente: String,
    val direccion: String,
    val detalle: String,
    val total: Double,
    val estado: EstadoPedido,
    val creadoEn: Long,
    val fotoEvidencia: String?
)

class ConversorEstados {
    @TypeConverter fun aTexto(e: EstadoPedido): String = e.name
    @TypeConverter fun aEnum(t: String): EstadoPedido = EstadoPedido.valueOf(t)
}

// Mappers entidad <-> dominio (en el mismo archivo)
fun PedidoEntidad.aDominio() = Pedido(id, cliente, direccion, detalle,
                                      total, estado, creadoEn, fotoEvidencia)
fun Pedido.aEntidad() = PedidoEntidad(if (id == 0L) 0 else id, cliente,
                                      direccion, detalle, total, estado,
                                      creadoEn, fotoEvidencia)

Paso 3 · El DAO

// datos/PedidoDao.kt
@Dao
interface PedidoDao {

    @Query("SELECT * FROM pedidos ORDER BY creadoEn DESC")
    fun observarTodos(): Flow<List<PedidoEntidad>>

    @Query("SELECT * FROM pedidos WHERE id = :id")
    suspend fun buscarPorId(id: Long): PedidoEntidad?

    @Query("SELECT COUNT(*) FROM pedidos WHERE estado != 'ENTREGADO'")
    fun contarActivos(): Flow<Int>

    @Upsert suspend fun guardar(pedido: PedidoEntidad): Long
    @Delete suspend fun borrar(pedido: PedidoEntidad)
}

Paso 4 · La base de datos (singleton)

// datos/AppBaseDatos.kt
@Database(entities = [PedidoEntidad::class],
          version = 1, exportSchema = false)
abstract class AppBaseDatos : RoomDatabase() {
    abstract fun pedidoDao(): PedidoDao

    companion object {
        @Volatile private var instancia: AppBaseDatos? = null

        fun obtener(contexto: Context): AppBaseDatos =
            instancia ?: synchronized(this) {
                instancia ?: Room.databaseBuilder(
                    contexto.applicationContext,
                    AppBaseDatos::class.java, "pedidos.db"
                ).addTypeConverter(ConversorEstados()).build()
                 .also { instancia = it }
            }
    }
}

Paso 5 · Sembrar datos de prueba

// En MainActivity, temporal hasta el sprint 7:
lifecycleScope.launch {
    val dao = AppBaseDatos.obtener(applicationContext).pedidoDao()
    if (dao.contarActivos().first() == 0 &&
        dao.buscarPorId(1) == null) {
        DatosPrueba.pedidos().forEach { dao.guardar(it.aEntidad()) }
    }
}
Teoría de apoyo: entidades/DAO/consultas vivas → Manual 03 · Capítulo 6. ¿Qué es kapt/KSP? → Glosario · «KSP / kapt». Flows → Glosario · «Flow».
Checkpoint: compila y ejecuta dos veces. Los pedidos de prueba ya viven en pedidos.db — verifica con App Inspection → Database Inspector en Android Studio.

4 · El repositorio

Sprint 4 ~15 min

Objetivo: una única puerta de entrada a los datos. Ni la interfaz ni los ViewModels tocarán el DAO directamente — cuando llegue el servidor (sprint 9), solo este archivo cambiará.

Paso 1 · Crear el repositorio

// datos/PedidosRepositorio.kt
package com.pedidos.app.datos

class PedidosRepositorio(private val dao: PedidoDao) {

    // Dominio puro hacia afuera; la tabla queda como detalle interno
    fun observar(): Flow<List<Pedido>> =
        dao.observarTodos().map { lista -> lista.map { it.aDominio() } }

    fun contarActivos(): Flow<Int> = dao.contarActivos()

    suspend fun buscar(id: Long): Pedido? =
        dao.buscarPorId(id)?.aDominio()

    suspend fun guardar(pedido: Pedido): Long =
        dao.guardar(pedido.aEntidad())

    suspend fun borrar(pedido: Pedido) = dao.borrar(pedido.aEntidad())

    suspend fun cambiarEstado(id: Long, nuevo: EstadoPedido) {
        dao.buscarPorId(id)?.let {
            dao.guardar(it.copy(estado = nuevo))
        }
    }

    companion object {
        @Volatile private var instancia: PedidosRepositorio? = null

        fun obtener(contexto: Context): PedidosRepositorio =
            instancia ?: synchronized(this) {
                instancia ?: PedidosRepositorio(
                    AppBaseDatos.obtener(contexto).pedidoDao()
                ).also { instancia = it }
            }
    }
}

Paso 2 · Semilla dentro del repositorio

Mejoramos el sprint 3: la siembra de prueba vive aquí y solo ocurre una vez, con la base vacía:

private val alcance = CoroutineScope(SupervisorJob() + Dispatchers.IO)

init {
    alcance.launch {
        if (dao.buscarPorId(1) == null) {
            DatosPrueba.pedidos().forEach { dao.guardar(it.aEntidad()) }
        }
    }
}
// Elimina el bloque temporal que pusiste en MainActivity en el sprint 3

Por qué esta capa vale oro

Sprint futuroCambio necesario
9 · RetrofitAñadir métodos de red AQUÍ; nadie más se entera
10 · SincronizaciónLógica servidor↔Room encapsulada aquí
16 · PruebasInyectar un repositorio falso es trivial
Teoría de apoyo: repositorio como fuente única → Manual 03 · Capítulo 6. MVVM y capas → Manual 03 · Capítulo 5.
Checkpoint: compila y ejecuta: los pedidos de prueba siguen apareciendo en Database Inspector, pero ahora sembrados por el repositorio. MainActivity quedó sin lógica de datos.

5 · Listado estático

Sprint 5 ~20 min

Objetivo: primera pantalla de verdad. Truco de desarrolladores senior: la interfaz primero con datos falsos — así validas el diseño sin esperar a la lógica. El ViewModel real llega en el sprint 6.

Paso 1 · Colores por estado

// ui/listado/ColoresEstado.kt
package com.pedidos.app.ui.listado

fun ColorEstado(estado: EstadoPedido): Color = when (estado) {
    EstadoPedido.PENDIENTE      -> Color(0xFFF59E0B)   // ámbar
    EstadoPedido.EN_PREPARACION -> Color(0xFF3B82F6)   // azul
    EstadoPedido.EN_CAMINO      -> Color(0xFF8B5CF6)   // violeta
    EstadoPedido.ENTREGADO      -> Color(0xFF22C55E)   // verde
    EstadoPedido.CANCELADO      -> Color(0xFFEF4444)   // rojo
}

Paso 2 · La tarjeta de pedido

// ui/listado/TarjetaPedido.kt
@Composable
fun TarjetaPedido(pedido: Pedido, alAbrir: () -> Unit) {
    Card(
        onClick = alAbrir,
        modifier = Modifier.fillMaxWidth()
    ) {
        Column(Modifier.padding(16.dp)) {
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text("#${pedido.id} · ${pedido.cliente}",
                     style = MaterialTheme.typography.titleMedium,
                     fontWeight = FontWeight.SemiBold)

                // Chip de estado
                Text(
                    pedido.estado.name.lowercase().replace('_', ' '),
                    style = MaterialTheme.typography.labelSmall,
                    color = Color.White,
                    modifier = Modifier
                        .clip(RoundedCornerShape(50))
                        .background(ColorEstado(pedido.estado))
                        .padding(horizontal = 8.dp, vertical = 3.dp)
                )
            }
            Text(pedido.direccion,
                 style = MaterialTheme.typography.bodySmall)
            Text("S/. ${"%.2f".format(pedido.total)}",
                 style = MaterialTheme.typography.titleSmall,
                 color = MaterialTheme.colorScheme.primary)
        }
    }
}

Paso 3 · La pantalla completa

// ui/listado/PantallaListado.kt
@Composable
fun PantallaListado(alAbrir: (Long) -> Unit) {
    // TEMPORAL: datos falsos hasta el sprint 7
    val pedidos = remember { DatosPrueba.pedidos() }

    Scaffold(
        topBar = { TopAppBar(title = { Text("Pedidos del día") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { /* sprint 12 */ }) {
                Icon(Icons.Default.Add, contentDescription = "Nuevo pedido")
            }
        }
    ) { relleno ->
        LazyColumn(
            modifier = Modifier.padding(relleno),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
            items(pedidos, key = { it.creadoEn }) { pedido ->
                TarjetaPedido(pedido) { alAbrir(pedido.id) }
            }
        }
    }
}

Y en MainActivity, dentro de setContent:

PedidosTheme {
    Surface(Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background) {
        PantallaListado(alAbrir = { /* sprint 8 */ })
    }
}
Teoría de apoyo: Scaffold y componentes → Manual 04 · Capítulo 6. Listas perezosas con claves → Manual 03 · Capítulo 10 y Manual 04 · Capítulo 12.
Checkpoint: la aplicación muestra tres tarjetas con cliente, dirección, total y chip de estado coloreado. Desliza: scroll fluido. Prueba el modo oscuro del emulador — los colores del tema se adaptan solos.

6 · Estado con ViewModel

Sprint 6 ~20 min

Objetivo: reemplazar los datos falsos por el flujo real del repositorio, con la arquitectura unidireccional: el ViewModel expone estado, la pantalla solo renderiza y reporta eventos.

Paso 1 · Estado de pantalla

// logica/ListadoUiState.kt
package com.pedidos.app.logica

data class ListadoUiState(
    val pedidos: List<Pedido> = emptyList(),
    val soloActivos: Boolean = true,
    val cargando: Boolean = true
)

Paso 2 · El ViewModel

El repositorio emite la lista completa; el filtro vive dentro del propio estado de pantalla y filtrar() proyecta lo que la interfaz ve:

// logica/ListadoViewModel.kt
class ListadoViewModel(
    private val repo: PedidosRepositorio
) : ViewModel() {

    private val _estado = MutableStateFlow(ListadoUiState())
    val estado: StateFlow<ListadoUiState> = _estado.asStateFlow()

    // Memoria interna: la lista completa sin filtrar
    private var pedidosCompletos: List<Pedido> = emptyList()

    init {
        viewModelScope.launch {
            // Consulta viva: se reemite CADA vez que cambie la tabla
            repo.observar().collect { lista ->
                pedidosCompletos = lista
                _estado.update { actual ->
                    actual.copy(
                        pedidos = filtrar(lista, actual.soloActivos),
                        cargando = false
                    )
                }
            }
        }
    }

    private fun filtrar(pedidos: List<Pedido>, soloActivos: Boolean) =
        if (soloActivos) pedidos.filter { !it.estado.esFinal } else pedidos

    // Evento que sube: recalcula desde la fuente completa
    fun alternarFiltro() {
        _estado.update { actual ->
            val nuevo = !actual.soloActivos
            actual.copy(
                soloActivos = nuevo,
                pedidos = filtrar(pedidosCompletos, nuevo)
            )
        }
    }

    companion object {
        // Fábrica: así el ViewModel recibe su dependencia
        val Fabrica = viewModelFactory {
            initializer {
                val app = this[APPLICATION] as Application
                ListadoViewModel(PedidosRepositorio.obtener(app))
            }
        }
    }
}
  • Fuente única de verdad: Room para datos, _estado para la pantalla; nunca dos copias que diverjan.
  • Los eventos no consultan de nuevo: recalculan sobre lo ya conocido.
  • La fábrica inyecta el repositorio sin necesitar Hilt todavía.

Paso 3 · Conectar la pantalla

// En PantallaListado.kt — reemplaza el bloque TEMPORAL:
@Composable
fun PantallaListado(
    alAbrir: (Long) -> Unit,
    vm: ListadoViewModel = viewModel(factory = ListadoViewModel.Fabrica)
) {
    val estado by vm.estado.collectAsStateWithLifecycle()

    Scaffold(/* igual que antes */) { relleno ->
        Column(Modifier.padding(relleno)) {

            Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
                FilterChip(
                    selected = estado.soloActivos,
                    onClick = vm::alternarFiltro,
                    label = { Text("Solo activos") }
                )
            }

            if (estado.cargando) {
                CircularProgressIndicator(Modifier.padding(32.dp))
            }
            LazyColumn(/* ... */) {
                items(estado.pedidos, key = { it.id }) { pedido ->
                    TarjetaPedido(pedido) { alAbrir(pedido.id) }
                }
            }
        }
    }
}
Teoría de apoyo: elevación de estado y UDF → Manual 04 · Capítulos 8–10. collectAsStateWithLifecycle → Manual 03 · Capítulo 3. combine → Manual 01 (flujos).
Checkpoint: el listado muestra los pedidos desde Room (ya sin datos falsos). Al tocar «Solo activos» desaparece la nota entregada. Gira el emulador: nada se pierde ni se recarga doble.

7 · Acciones en el listado

Sprint 7 ~20 min

Objetivo: la aplicación deja de ser un catálogo: deslizar una tarjeta marca el pedido como entregado, con botón «Deshacer». El gesto dispara un evento; Room notifica; la lista se actualiza sola.

Paso 1 · Dependencia de Material Extended

implementation("androidx.compose.material:material-icons-extended")

Paso 2 · Eventos en el ViewModel

// Añade a ListadoViewModel:
private var ultimoBorrado: Pedido? = null

fun entregar(pedido: Pedido) {
    ultimoBorrado = pedido
    viewModelScope.launch {
        repo.cambiarEstado(pedido.id, EstadoPedido.ENTREGADO)
    }
}

fun deshacer() {
    val pedido = ultimoBorrado ?: return
    viewModelScope.launch {
        repo.cambiarEstado(pedido.id, pedido.estado)
    }
}

Paso 3 · Deslizar para entregar

// ui/listado/FilaDeslizable.kt
@Composable
fun FilaDeslizable(
    pedido: Pedido,
    alEntregar: () -> Unit,
    contenido: @Composable () -> Unit
) {
    val estado = rememberSwipeToDismissBoxState(
        confirmValueChange = { valor ->
            if (valor == SwipeToDismissBoxValue.EndToStart) {
                alEntregar()
                true   // permite que se cierre; Room lo saca del filtro
            } else false
        }
    )
    SwipeToDismissBox(
        state = estado,
        enableDismissFromStartToEnd = false,     // solo hacia la izquierda
        backgroundContent = {
            Box(
                Modifier
                    .fillMaxSize()
                    .clip(RoundedCornerShape(12.dp))
                    .background(Color(0xFF22C55E))
                    .padding(horizontal = 24.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(Icons.Default.CheckCircle, "Entregar",
                     tint = Color.White)
            }
        },
        content = { contenido() }
    )
}

Paso 4 · Snackbar con deshacer

// En PantallaListado:
val snackbar = remember { SnackbarHostState() }

// Dentro del Scaffold:
snackbarHost = { SnackbarHost(snackbar) }

// Al entregar (desde el lambda de FilaDeslizable):
alEntregar = {
    vm.entregar(pedido)
    alcance.launch {                       // alcance = rememberCoroutineScope()
        if (snackbar.showSnackbar(
                "Pedido entregado", actionLabel = "Deshacer"
            ) == SnackbarResult.ActionPerformed) vm.deshacer()
    }
}

// Y en Scaffold añade snackbarHost = { SnackbarHost(snackbar) }
  • El snackbar NO va al UiState: es efímero y vive en la pantalla.
  • Deshacer restaura el estado ANTERIOR del pedido (guardado antes del cambio).
  • La lista se refresca sola: nadie llama a «recargar».
Teoría de apoyo: gestos y SwipeToDismissBox → Manual 04 · Capítulo 15. Snackbars y eventos → Manual 04 · Capítulo 10.
Checkpoint: desliza «Julio Vargas» a la izquierda: aparece el fondo verde y el pedido pasa a entregado (desaparece del filtro activos). Toca «Deshacer»: vuelve a pendiente sin recargas visibles.

8 · Detalle y navegación

Sprint 8 ~25 min

Objetivo: tocar una tarjeta abre su ficha completa, con línea de tiempo del estado y botones para avanzarlo. Cierre de la Parte II: la aplicación ya es útil en local.

Paso 1 · Dependencia de navegación

implementation("androidx.navigation:navigation-compose:2.8.5")

Paso 2 · El grafo en MainActivity

// Rutas como constantes — simples y sin serialización por ahora
object Rutas {
    const val LISTADO = "listado"
    const val DETALLE = "detalle/{id}"
    fun detalle(id: Long) = "detalle/$id"
}

@Composable
fun AppPedidos() {
    val nav = rememberNavController()

    NavHost(navController = nav, startDestination = Rutas.LISTADO) {
        composable(Rutas.LISTADO) {
            PantallaListado(
                alAbrir = { id -> nav.navigate(Rutas.detalle(id)) }
            )
        }
        composable(Rutas.DETALLE) { entrada ->
            val id = entrada.arguments?.getString("id")?.toLongOrNull() ?: 0L
            PantallaDetalle(
                idPedido = id,
                alVolver = { nav.popBackStack() }
            )
        }
    }
}

// setContent ahora llama a AppPedidos()

Paso 3 · ViewModel del detalle

// logica/DetalleViewModel.kt
class DetalleViewModel(
    private val repo: PedidosRepositorio,
    private val idPedido: Long
) : ViewModel() {

    var pedido by mutableStateOf<Pedido?>(null)
        private set

    init {
        viewModelScope.launch {
            pedido = repo.buscar(idPedido)
        }
    }

    fun avanzarEstado() {
        val actual = pedido ?: return
        val siguiente = when (actual.estado) {
            EstadoPedido.PENDIENTE -> EstadoPedido.EN_PREPARACION
            EstadoPedido.EN_PREPARACION -> EstadoPedido.EN_CAMINO
            EstadoPedido.EN_CAMINO -> EstadoPedido.ENTREGADO
            else -> return   // estados finales no avanzan
        }
        viewModelScope.launch {
            repo.cambiarEstado(actual.id, siguiente)
            pedido = repo.buscar(actual.id)
        }
    }

    companion object {
        fun fabrica(id: Long) = viewModelFactory {
            initializer {
                val app = this[APPLICATION] as Application
                DetalleViewModel(PedidosRepositorio.obtener(app), id)
            }
        }
    }
}

Paso 4 · La pantalla de detalle

// ui/detalle/PantallaDetalle.kt
@Composable
fun PantallaDetalle(
    idPedido: Long,
    alVolver: () -> Unit,
    vm: DetalleViewModel = viewModel(
        factory = DetalleViewModel.fabrica(idPedido),
        key = "detalle-$idPedido"
    )
) {
    val pedido = vm.pedido ?: return   // aún cargando

    Scaffold(topBar = {
        TopAppBar(
            title = { Text("Pedido #${pedido.id}") },
            navigationIcon = {
                IconButton(onClick = alVolver) {
                    Icon(Icons.AutoMirrored.Filled.ArrowBack, "Volver")
                }
            }
        )
    }) { relleno ->
        Column(Modifier.padding(relleno).padding(16.dp)) {

            // Línea de tiempo del ciclo de vida
            val pasos = listOf(
                EstadoPedido.PENDIENTE, EstadoPedido.EN_PREPARACION,
                EstadoPedido.EN_CAMINO, EstadoPedido.ENTREGADO)
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                pasos.forEach { paso ->
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Icon(
                            if (paso.ordinal <= pedido.estado.ordinal)
                                Icons.Default.CheckCircle
                            else Icons.Default.RadioButtonUnchecked,
                            contentDescription = paso.name,
                            tint = ColorEstado(paso)
                        )
                        Text(paso.name.lowercase().replace('_', '\n'),
                             style = MaterialTheme.typography.labelSmall,
                             textAlign = TextAlign.Center)
                    }
                }
            }

            HorizontalDivider(Modifier.padding(vertical = 16.dp))
            Ficha("Cliente", pedido.cliente)
            Ficha("Dirección", pedido.direccion)
            Ficha("Detalle", pedido.detalle)
            Ficha("Total", "S/. ${"%.2f".format(pedido.total)}")
            Ficha("Registrado",
                  SimpleDateFormat("dd MMM yyyy, HH:mm", Locale("es", "PE"))
                      .format(Date(pedido.creadoEn)))

            Spacer(Modifier.height(24.dp))
            if (!pedido.estado.esFinal) {
                Button(onClick = vm::avanzarEstado,
                       modifier = Modifier.fillMaxWidth()) {
                    Text("Avanzar a ${siguienteEtiqueta(pedido.estado)}")
                }
            } else {
                Text("Este pedido ya está cerrado.",
                     style = MaterialTheme.typography.bodyMedium)
            }
        }
    }
}

@Composable
private fun Ficha(etiqueta: String, valor: String) {
    Column(Modifier.padding(vertical = 4.dp)) {
        Text(etiqueta.uppercase(),
             style = MaterialTheme.typography.labelSmall,
             color = MaterialTheme.colorScheme.primary)
        Text(valor, style = MaterialTheme.typography.bodyLarge)
    }
}

La función auxiliar siguienteEtiqueta() devuelve el nombre legible del próximo estado («en preparación», «en camino», «entregado») con el mismo patrón when del ViewModel.

  • key en el viewModel evita reusar un VM de otro pedido al navegar rápido.
  • avanzarEstado relee de Room tras escribir: una sola verdad.
  • Estados finales deshabilitan la acción — la máquina de estados manda.
Teoría de apoyo: Navigation Compose y rutas → Manual 04 · Capítulo 16. Máquina de estados y UI → Manual 03 · Capítulo 8.
Checkpoint: toca «Rosa Quispe»: se abre su ficha con la línea de tiempo. Pulsa «Avanzar» dos veces: pasa a «en camino». Regresa: el chip del listado ya es violeta, sin recargar nada. Mata la app desde recientes y vuelve: todo sigue ahí.

9 · Contrato con el API

Sprint 9 ~25 min

Objetivo: definir cómo hablará la aplicación con tu servidor de backend. Como vienes del mundo servidor, este sprint te resulta familiar: es un cliente HTTP tipado con contrato explícito.

Paso 1 · Dependencias y plugin

// raíz build.gradle.kts:
kotlin("plugin.serialization") version "2.0.21" apply false

// app/build.gradle.kts:
id("org.jetbrains.kotlin.plugin.serialization")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

Paso 2 · El DTO — idioma de la red

El servidor habla JSON en camelCase y sin enums propios: eso se traduce en la frontera, nunca dentro de la lógica.

// datos/remoto/PedidoDto.kt
package com.pedidos.app.datos.remoto

@Serializable
data class PedidoDto(
    val id: Long = 0,
    val cliente: String,
    val direccion: String,
    val detalle: String,
    val total: Double,
    val estado: String,
    val creadoEn: Long = 0
)

fun PedidoDto.aDominio() = Pedido(
    id = id, cliente = cliente, direccion = direccion,
    detalle = detalle, total = total,
    estado = runCatching { EstadoPedido.valueOf(estado) }
                .getOrDefault(EstadoPedido.PENDIENTE),
    creadoEn = creadoEn
)

fun Pedido.aDto() = PedidoDto(
    id = id, cliente = cliente, direccion = direccion,
    detalle = detalle, total = total,
    estado = estado.name, creadoEn = creadoEn
)

Paso 3 · La interfaz del servicio

// datos/remoto/PedidosApi.kt
interface PedidosApi {

    @GET("pedidos")
    suspend fun listar(): List<PedidoDto>

    @POST("pedidos")
    suspend fun crear(@Body pedido: PedidoDto): PedidoDto

    @PATCH("pedidos/{id}/estado")
    suspend fun cambiarEstado(
        @Path("id") id: Long,
        @Body cuerpo: Map<String, String>
    ): PedidoDto
}

object RedPedidos {
    val api: PedidosApi by lazy {
        val json = Json { ignoreUnknownKeys = true }

        Retrofit.Builder()
            .baseUrl("http://10.0.2.2:8080/")   // localhost del PC desde el emulador
            .ConverterFactory(
                json.asConverterFactory("application/json".toMediaType())
            )
            .client(
                OkHttpClient.Builder()
                    .addInterceptor(HttpLoggingInterceptor().apply {
                        level = HttpLoggingInterceptor.Level.BODY
                    })
                    .build()
            )
            .build()
            .create(PedidosApi::class.java)
    }
}

Paso 4 · Un servidor de mentira (por ahora)

Mientras tu backend real no esté listo, un archivo estático basta. Crea db.json en tu PC:

{
  "pedidos": [
    { "id": 101, "cliente": "Cliente Remoto",
      "direccion": "Av. Arequipa 1234", "detalle": "Prueba API",
      "total": 99.90, "estado": "PENDIENTE", "creadoEn": 1735000000000 }
  ]
}
npx json-server db.json --port 8080
# → recursos disponibles: http://localhost:8080/pedidos

Paso 5 · Permitir HTTP claro (solo desarrollo)

<!-- AndroidManifest.xml -->
<application
    android:usesCleartextTraffic="true"
    ... >
usesCleartextTraffic es SOLO para desarrollo con el mock local. En producción usaremos HTTPS con certificado propio (sprint 17) y esta línea desaparece.

Paso 6 · Prueba de humo

// Temporal en onCreate de MainActivity:
lifecycleScope.launch {
    try {
        val remotos = RedPedidos.api.listar()
        Log.d("PEDIDOS", "Recibidos ${remotos.size} del servidor")
    } catch (e: Exception) {
        Log.e("PEDIDOS", "Fallo de red", e)
    }
}
Teoría de apoyo: Retrofit y corrutinas suspend → Manual 03 · Capítulo 7. ¿Por qué DTO separado de entidad? → Glosario · «DTO».
Checkpoint: con el mock corriendo, Logcat muestra «Recibidos 1 del servidor» y el cuerpo JSON en verde gracias al interceptor. Apaga el mock: verás el fallo limpio en el catch — así se comportará offline.

10 · Sincronización

Sprint 10 ~25 min

Objetivo: unir los dos mundos. La aplicación descarga pedidos del servidor hacia Room y sigue funcionando sin red — filosofía offline-first: Room manda en pantalla, el servidor alimenta a Room.

Paso 1 · El sincronizador

// logica/SincronizadorPedidos.kt
package com.pedidos.app.logica

class SincronizadorPedidos(
    private val api: PedidosApi,
    private val dao: PedidoDao
) {
    sealed class Resultado {
        object Ok : Resultado()
        data class Fallo(val causa: Exception) : Resultado()
    }

    suspend fun sincronizar(): Resultado = try {
        val remotos = api.listar()
        remotos.forEach { dto ->
            val remoto = dto.aDominio()
            val local = dao.buscarPorId(remoto.id)

            // Regla de fusión: gana el más reciente; si no existe, se crea
            if (local == null || remoto.creadoEn >= local.creadoEn) {
                dao.guardar(remoto.aEntidad())
            }
        }
        Resultado.Ok
    } catch (e: Exception) {
        Resultado.Fallo(e)
    }
}

Paso 2 · Cableado en el ViewModel

// ListadoViewModel — añade al UiState:  val sincronizando: Boolean = false

class ListadoViewModel(
    private val repo: PedidosRepositorio,
    private val sincronizador: SincronizadorPedidos
) : ViewModel() {

    // ... lo del sprint 6 ...

    private val _aviso = MutableSharedFlow<String>()
    val aviso: SharedFlow<String> = _aviso.asSharedFlow()

    fun sincronizar() {
        viewModelScope.launch {
            _estado.update { it.copy(sincronizando = true) }
            when (val r = sincronizador.sincronizar()) {
                is SincronizadorPedidos.Resultado.Ok ->
                    _aviso.emit("Sincronizado")
                is SincronizadorPedidos.Resultado.Fallo ->
                    _aviso.emit("Sin conexión · mostrando datos guardados")
            }
            _estado.update { it.copy(sincronizando = false) }
            // Room notifica solo; NO hay que releer aquí
        }
    }

    companion object {
        val Fabrica = viewModelFactory {
            initializer {
                val app = this[APPLICATION] as Application
                val contexto = app.applicationContext
                ListadoViewModel(
                    PedidosRepositorio.obtener(app),
                    SincronizadorPedidos(
                        RedPedidos.api,
                        AppBaseDatos.obtener(contexto).pedidoDao()
                    )
                )
            }
        }
    }
}

Paso 3 · Tirar para refrescar

// En PantallaListado — envuelve la LazyColumn:
val estadoAviso by vm.aviso.collectAsStateWithLifecycle(initialValue = "")
LaunchedEffect(Unit) { vm.sincronizar() }   // primera carga

PullToRefreshBox(
    isRefreshing = estado.sincronizando,
    onRefresh = vm::sincronizar,
    modifier = Modifier.fillMaxSize()
) {
    LazyColumn(/* igual que antes */) { /* ... */ }
}

// Y el aviso efímero:
LaunchedEffect(Unit) {
    vm.aviso.collect { mensaje ->
        snackbar.showSnackbar(mensaje)
    }
}

PullToRefreshBox vive en Material 3 desde 1.3 — ya está en tu BOM, sin dependencia extra.

La regla de oro del diseño

SituaciónQuién manda
Lectura en pantallaSiempre Room (instantáneo, funciona sin red)
Nuevos datos del servidorEscriben en Room; la UI se entera sola
Fallo de redNo es error visible: es un aviso amable
Teoría de apoyo: patrón offline-first → Manual 03 · Capítulo 6. SharedFlow para eventos únicos → Glosario · «StateFlow vs SharedFlow».
Checkpoint: con el mock activo, el pedido #101 aparece tras abrir la app o deslizar hacia abajo. Apaga el mock y refresca: snackbar «Sin conexión» y el listado intacto. Nada crashea.

11 · Ingreso y sesión

Sprint 11 ~30 min

Objetivo: pantalla de ingreso con token persistido en DataStore e interceptor que firma cada petición. Al reabrir la aplicación, sesión conservada.

Paso 1 · Dependencias

implementation("androidx.datastore:datastore-preferences:1.1.1")

Paso 2 · Almacén de sesión

// datos/SesionRepositorio.kt
package com.pedidos.app.datos

private val Context.sesionStore by preferencesDataStore("sesion")

class SesionRepositorio(private val contexto: Context) {

    private val CLAVE_TOKEN = stringPreferencesKey("token")
    private val CLAVE_USUARIO = stringPreferencesKey("usuario")

    val haySesion: Flow<Boolean> =
        contexto.sesionStore.data.map { it[CLAVE_TOKEN] != null }

    suspend fun guardar(token: String, usuario: String) {
        contexto.sesionStore.edit { preferencias ->
            preferencias[CLAVE_TOKEN] = token
            preferencias[CLAVE_USUARIO] = usuario
        }
    }

    suspend fun cerrar() {
        contexto.sesionStore.edit { it.clear() }
    }

    companion object {
        @Volatile private var instancia: SesionRepositorio? = null
        fun obtener(contexto: Context) = instancia ?: synchronized(this) {
            instancia ?: SesionRepositorio(
                contexto.applicationContext
            ).also { instancia = it }
        }
    }
}

Paso 3 · Extender el contrato del API

// En PedidosApi:
@Serializable
data class Credenciales(val usuario: String, val clave: String)

@POST("sesion")
suspend fun ingresar(@Body credenciales: Credenciales): Map<String, String>
// El servidor responde { "token": "...", "usuario": "..." }

Paso 4 · Interceptor de autenticación

OkHttp no conoce corrutinas, así que el token se cachea en memoria y un flujo lo mantiene al día:

// datos/remoto/InterceptorSesion.kt
class InterceptorSesion(private val sesion: SesionRepositorio) : Interceptor {

    private val alcance = CoroutineScope(Dispatchers.IO + SupervisorJob())

    @Volatile private var tokenActual: String? = null

    init {
        alcance.launch {
            sesion.hayToken().collect { tokenActual = it }
        }
    }

    override fun intercept(cadena: Interceptor.Chain): Response {
        val peticion = cadena.request().newBuilder().apply {
            tokenActual?.let { addHeader("Authorization", "Bearer $it") }
        }.build()
        return cadena.proceed(peticion)
    }
}

// Añade a SesionRepositorio:
suspend fun hayToken(): String? =
    contexto.sesionStore.data.first()[CLAVE_TOKEN]

y en el builder de OkHttp: .addInterceptor(InterceptorSesion(SesionRepositorio.obtener(contexto))). Necesitarás pasarle un Context a RedPedidos — conviértelo en función fun api(contexto: Context).

Paso 5 · Mock con ingreso real

Reemplaza el mock por este mini-servidor Express (Node) que además valida credenciales — guárdalo como servidor.js:

const express = require('express')
const app = express()
app.use(express.json())

let siguienteId = 102
const pedidos = [{
  id: 101, cliente: 'Cliente Remoto', direccion: 'Av. Arequipa 1234',
  detalle: 'Prueba API', total: 99.9, estado: 'PENDIENTE',
  creadoEn: 1735000000000
}]

app.post('/sesion', (req, res) => {
  const { usuario, clave } = req.body
  if (usuario === 'repartidor' && clave === 'pedidos2026') {
    res.json({ token: 'demo-token-123', usuario })
  } else {
    res.status(401).json({ error: 'Credenciales inválidas' })
  }
})

app.get('/pedidos', (_req, res) => res.json(pedidos))
app.listen(8080, () => console.log('Mock en http://localhost:8080'))
npm init -y && npm i express
node servidor.js

Paso 6 · Pantalla de ingreso

// ui/ingreso/PantallaIngreso.kt
@Composable
fun PantallaIngreso(
    alEntrar: () -> Unit,
    vm: IngresoViewModel = viewModel(factory = IngresoViewModel.Fabrica)
) {
    val estado by vm.estado.collectAsStateWithLifecycle()

    Column(
        Modifier.fillMaxSize().padding(32.dp),
        verticalArrangement = Arrangement.Center
    ) {
        Icon(Icons.Default.DeliveryDining, null,
             modifier = Modifier.size(64.dp),
             tint = MaterialTheme.colorScheme.primary)
        Text("Pedidos",
             style = MaterialTheme.typography.headlineLarge)
        Text("Gestión de reparto · v0.x",
             style = MaterialTheme.typography.bodySmall)

        Spacer(Modifier.height(24.dp))
        OutlinedTextField(
            value = estado.usuario,
            onValueChange = vm::escribirUsuario,
            label = { Text("Usuario") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(Modifier.height(8.dp))
        OutlinedTextField(
            value = estado.clave,
            onValueChange = vm::escribirClave,
            label = { Text("Contraseña") },
            visualTransformation = PasswordVisualTransformation(),
            singleLine = true,
            isError = estado.error != null,
            supportingText = { estado.error?.let { Text(it) } },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = vm::ingresar, enabled = !estado.procesando,
               modifier = Modifier.fillMaxWidth()) {
            if (estado.procesando)
                CircularProgressIndicator(Modifier.size(18.dp),
                                         strokeWidth = 2.dp)
            else Text("Ingresar")
        }
    }
    // Navega cuando el ViewModel avisa de éxito
    LaunchedEffect(Unit) {
        vm.exito.collect { alEntrar() }
    }
}

Paso 7 · ViewModel y grafo condicional

// logica/IngresoViewModel.kt
data class IngresoUiState(
    val usuario: String = "", val clave: String = "",
    val procesando: Boolean = false, val error: String? = null
)

class IngresoViewModel(
    private val api: PedidosApi,
    private val sesion: SesionRepositorio
) : ViewModel() {

    private val _estado = MutableStateFlow(IngresoUiState())
    val estado = _estado.asStateFlow()

    private val _exito = MutableSharedFlow<Unit>()
    val exito = _exito.asSharedFlow()

    fun escribirUsuario(v: String) =
        _estado.update { it.copy(usuario = v, error = null) }
    fun escribirClave(v: String) =
        _estado.update { it.copy(clave = v, error = null) }

    fun ingresar() {
        viewModelScope.launch {
            _estado.update { it.copy(procesando = true) }
            try {
                val r = api.ingresar(
                    Credenciales(_estado.value.usuario, _estado.value.clave)
                )
                sesion.guardar(r["token"] ?: "", r["usuario"] ?: "")
                _exito.emit(Unit)
            } catch (e: Exception) {
                _estado.update {
                    it.copy(procesando = false,
                            error = "Usuario o clave incorrectos")
                }
            }
        }
    }
}
Por ahora todo fallo dice «incorrectos». En el sprint 16 distinguiremos 401 (credenciales) de timeout (red) con HttpException.
// AppPedidos(): decide el inicio según sesión guardada
var destinoInicio: String? by remember { mutableStateOf(null) }

LaunchedEffect(Unit) {
    destinoInicio = if (sesionRepo.haySesion.first())
        Rutas.LISTADO else Rutas.INGRESO
}

if (destinoInicio != null) {
    NavHost(navController = nav, startDestination = destinoInicio!!) {
        composable(Rutas.INGRESO) {
            PantallaIngreso(alEntrar = {
                nav.navigate(Rutas.LISTADO) {
                    popUpTo(Rutas.INGRESO) { inclusive = true }
                }
            })
        }
        // ... listado y detalle como antes ...
    }
}

// Y en el TopAppBar del listado, acción de salida:
IconButton(onClick = {
    alcance.launch { sesion.cerrar(); alSalir() }
}) { Icon(Icons.Default.Logout, "Cerrar sesión") }
Teoría de apoyo: DataStore vs SharedPreferences → Glosario · «DataStore». Interceptores de OkHttp → Glosario · «Interceptor».
Checkpoint: abre la app: pantalla de ingreso. Clave mal → error inline sin crasheo. repartidor / pedidos2026 → listado con el pedido remoto. Mata y reabre: entra directo. Logout → vuelve al ingreso y las peticiones ya no llevan Bearer (míralo en Logcat).

12 · Nuevo pedido

Sprint 12 ~25 min

Objetivo: el FAB cobra vida. Formulario con validación que guarda primero en Room (instantáneo) y luego intenta publicarlo en el servidor — si falla la red, nada se pierde.

Paso 1 · Ruta y navegación

// En Rutas:
const val NUEVO = "nuevo"

// En NavHost:
composable(Rutas.NUEVO) {
    PantallaNuevoPedido(
        alGuardar = { nav.popBackStack() }
    )
}

// Y el FAB del listado ya navega:
FloatingActionButton(onClick = { nav.navigate(Rutas.NUEVO) }) { ... }

Paso 2 · ViewModel del formulario

// logica/NuevoPedidoViewModel.kt
data class FormularioUiState(
    val cliente: String = "",
    val direccion: String = "",
    val detalle: String = "",
    val total: String = "",
    val errores: Map<String, String> = emptyMap(),
    val guardando: Boolean = false
) {
    val formularioValido: Boolean
        get() = cliente.isNotBlank() && direccion.isNotBlank() &&
                (total.toDoubleOrNull() ?: 0.0) > 0.0
}

class NuevoPedidoViewModel(
    private val repo: PedidosRepositorio,
    private val api: PedidosApi
) : ViewModel() {

    private val _estado = MutableStateFlow(FormularioUiState())
    val estado = _estado.asStateFlow()

    fun escribirCampo(campo: String, valor: String) {
        _estado.update { actual ->
            actual.copy(
                when (campo) {
                    "cliente" -> actual.copy(cliente = valor)
                    "direccion" -> actual.copy(direccion = valor)
                    "detalle" -> actual.copy(detalle = valor)
                    else -> actual.copy(total = valor.filter {
                        it.isDigit() || it == '.'
                    })
                },
                errores = emptyMap()
            )
        }
    }

    fun guardar() {
        val s = _estado.value

        // Validación explícita, campo por campo
        val errores = buildMap {
            if (s.cliente.isBlank()) put("cliente", "Indica el cliente")
            if (s.direccion.isBlank()) put("direccion", "Falta la dirección")
            val t = s.total.toDoubleOrNull() ?: 0.0
            if (t <= 0.0) put("total", "Total debe ser mayor a S/. 0")
        }
        if (errores.isNotEmpty()) {
            _estado.update { it.copy(errores = errores) }
            return
        }

        viewModelScope.launch {
            val pedido = Pedido(
                cliente = s.cliente.trim(),
                direccion = s.direccion.trim(),
                detalle = s.detalle.trim(),
                total = s.total.toDouble()
            )
            // 1) Local primero — nunca se pierde un pedido
            val id = repo.guardar(pedido)

            // 2) Servidor después — mejor esfuerzo
            runCatching { api.crear(pedido.copy(id = id).aDto()) }

            _exito.emit(Unit)
        }
    }
}

Paso 3 · La pantalla

// ui/nuevo/PantallaNuevoPedido.kt
@Composable
fun PantallaNuevoPedido(
    alGuardar: () -> Unit,
    vm: NuevoPedidoViewModel = viewModel(factory = ...)
) {
    val estado by vm.estado.collectAsStateWithLifecycle()

    Scaffold(topBar = {
        TopAppBar(
            title = { Text("Nuevo pedido") },
            navigationIcon = {
                IconButton(onClick = alGuardar) {
                    Icon(Icons.Default.Close, "Cancelar")
                }
            }
        )
    }) { relleno ->
        Column(
            Modifier.padding(relleno).padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
            OutlinedTextField(
                value = estado.cliente,
                onValueChange = { vm.escribirCampo("cliente", it) },
                label = { Text("Cliente") },
                isError = "cliente" in estado.errores,
                supportingText = { estado.errores["cliente"]?.let { Text(it) } },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            OutlinedTextField(
                value = estado.direccion,
                onValueChange = { vm.escribirCampo("direccion", it) },
                label = { Text("Dirección de entrega") },
                isError = "direccion" in estado.errores,
                supportingText = { estado.errores["direccion"]?.let { Text(it) } },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            OutlinedTextField(
                value = estado.detalle,
                onValueChange = { vm.escribirCampo("detalle", it) },
                label = { Text("Detalle (opcional)") },
                minLines = 3,
                modifier = Modifier.fillMaxWidth()
            )
            OutlinedTextField(
                value = estado.total,
                onValueChange = { vm.escribirCampo("total", it) },
                label = { Text("Total (S/.)") },
                prefix = { Text("S/. ") },
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Decimal),
                isError = "total" in estado.errores,
                supportingText = { estado.errores["total"]?.let { Text(it) } },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )

            Button(
                onClick = vm::guardar,
                enabled = estado.formularioValido && !estado.guardando,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(if (estado.guardando) "Guardando…" else "Registrar pedido")
            }
        }
    }

    LaunchedEffect(Unit) { vm.exito.collect { alGuardar() } }
}
  • El botón se desactiva solo: formularioValido es una propiedad derivada, no un estado duplicado.
  • Errores por campo en un mapa: la pantalla no sabe las reglas, solo las muestra.
  • Guardar local + push best-effort: el patrón que escalará a sincronización bidireccional.
Teoría de apoyo: formularios y validación → Manual 04 · Capítulo 11. Estados derivados vs duplicados → Manual 04 · Capítulo 9.
Checkpoint: toca el FAB → formulario. Envía vacío: errores bajo cada campo, botón deshabilitado. Escribe un pedido válido: regresa al listado y la tarjeta nueva está arriba. En la consola del mock aparece el POST recibido.

13 · Evidencia fotográfica

Sprint 13 ~30 min

Objetivo: el repartidor fotografía la entrega. Usamos la cámara del sistema vía TakePicture — cero código de cámara propia y sin declarar el permiso CAMERA.

Paso 1 · Dónde viven las fotos: FileProvider

<!-- res/xml/rutas_fotos.xml -->
<paths>
    <external-files-path name="fotos" path="fotos/" />
</paths>

<!-- AndroidManifest.xml, dentro de application -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/rutas_fotos" />
</provider>

Paso 2 · Contrato de cámara en Compose

// ui/detalle/CapturaFoto.kt
package com.pedidos.app.ui.detalle

@Composable
fun rememberCapturaFoto(
    alCapturar: (String?) -> Unit   // entrega el NOMBRE del archivo
): () -> Unit {
    val contexto = LocalContext.current

    fun crearArchivo(): Pair<File, Uri> {
        val archivo = File(
            contexto.getExternalFilesDir("fotos"),
            "pedido_${System.currentTimeMillis()}.jpg"
        ).apply { createNewFile() }
        val uri = FileProvider.getUriForFile(
            contexto, "${contexto.packageName}.fileprovider", archivo
        )
        return archivo to uri
    }

    var nombrePendiente by remember { mutableStateOf<String?>(null) }

    val lanzador = rememberLauncherForActivityResult(
        ActivityResultContracts.TakePicture()
    ) { exito ->
        alCapturar(if (exito) nombrePendiente else null)
    }

    return {
        val (archivo, uri) = crearArchivo()
        nombrePendiente = archivo.name    // solo el nombre, no la ruta
        lanzador.launch(uri)
    }
}

Paso 3 · Guardar y mostrar

implementation("io.coil-kt:coil-compose:2.7.0") // AsyncImage

En PedidosRepositorio:

suspend fun asignarFoto(id: Long, nombreArchivo: String) {
    dao.buscarPorId(id)?.let {
        dao.guardar(it.copy(fotoEvidencia = nombreArchivo))
    }
}

En el ViewModel del detalle:

fun asignarFoto(id: Long, nombre: String) {
    viewModelScope.launch { repo.asignarFoto(id, nombre) }
}

Y en PantallaDetalle:

// Botón condicionado a la regla de negocio:
if (pedido.estado == EstadoPedido.EN_CAMINO) {
    val capturar = rememberCapturaFoto { nombre ->
        if (nombre != null) vm.asignarFoto(pedido.id, nombre)
    }
    Button(onClick = capturar, modifier = Modifier.fillMaxWidth()) {
        Icon(Icons.Default.PhotoCamera, null)
        Spacer(Modifier.width(8.dp))
        Text(if (pedido.fotoEvidencia == null)
                 "Registrar entrega con foto" else "Retomar foto")
    }
}

// Visualización con Coil — reconstruimos la ruta completa al mostrar:
pedido.fotoEvidencia?.let { nombre ->
    val contexto = LocalContext.current
    val archivo = File(contexto.getExternalFilesDir("fotos"), nombre)

    Spacer(Modifier.height(16.dp))
    AsyncImage(
        model = archivo,
        contentDescription = "Evidencia de la entrega",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .fillMaxWidth()
            .height(220.dp)
            .clip(RoundedCornerShape(12.dp))
    )
}
  • Guardamos SOLO el nombre del archivo: las URIs content:// caducan entre procesos.
  • Sin permiso CAMERA: la app de cámara del sistema escribe en nuestro directorio privado vía FileProvider.
  • El botón aparece solo en «en camino»: la máquina de estados manda sobre la interfaz.
Teoría de apoyo: FileProvider y content URIs → Glosario · «FileProvider». ActivityResultContracts → Manual 03 · Capítulo 11.
Checkpoint: lleva un pedido a «en camino»: aparece el botón de foto. Tómala (el emulador trae cámara virtual): la imagen se ve en la ficha. Mata y reabre la aplicación: la evidencia sigue ahí, servida desde disco.

14 · Ubicación del reparto

Sprint 14 ~30 min

Objetivo: registrar dónde se entregó cada pedido. De paso, la primera migración real de Room: cambiamos el esquema sin perder datos.

Paso 1 · Migración de esquema

// AppBaseDatos.kt — versión 2:
val MIGRACION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE pedidos ADD COLUMN latitud REAL DEFAULT NULL")
        db.execSQL(
            "ALTER TABLE pedidos ADD COLUMN longitud REAL DEFAULT NULL")
    }
}

@Database(entities = [PedidoEntidad::class],
          version = 2, exportSchema = false)
abstract class AppBaseDatos : RoomDatabase() {
    // ...
    ).addTypeConverter(ConversorEstados())
     .addMigrations(MIGRACION_1_2)
     .build()

Y en las dos clases de datos (entidad y dominio):

val latitud: Double? = null,
val longitud: Double? = null // al final de ambas data classes
Los mappers también crecen: añade los dos campos nuevos en aDominio() y aEntidad(). El compilador de KSP te avisará si algo queda suelto.

Paso 2 · Dependencia y permisos

implementation("com.google.android.gms:play-services-location:21.3.0")

<!-- Manifest -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Paso 3 · Pedir permiso y leer ubicación

// ui/detalle/CapturaUbicacion.kt
@Composable
fun BotonUbicacion(alRegistrar: (Double, Double) -> Unit) {
    val contexto = LocalContext.current

    val lanzador = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { concesiones ->
        if (concesiones.values.all { it }) {
            val cliente = LocationServices
                .getFusedLocationProviderClient(contexto)
            cliente.lastLocation.addOnSuccessListener { ubi ->
                ubi?.let { alRegistrar(it.latitude, it.longitude) }
            }
        }
    }

    Button(onClick = {
        lanzador.launch(arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION))
    }) {
        Icon(Icons.Default.LocationOn, null)
        Spacer(Modifier.width(8.dp))
        Text("Marcar punto de entrega")
    }
}

En el detalle (solo cuando está en camino):

if (pedido.estado == EstadoPedido.EN_CAMINO) {
    BotonUbicacion { lat, lng -> vm.asignarUbicacion(pedido.id, lat, lng) }
}

// Coordenadas ya guardadas se muestran como enlace a Maps:
pedido.latitud?.let { lat ->
    val contexto = LocalContext.current
    TextButton(onClick = {
        contexto.startActivity(
            Intent(Intent.ACTION_VIEW,
                   Uri.parse("geo:$lat,${pedido.longitud}?q=$lat,${pedido.longitud}"))
        )
    }) {
        Icon(Icons.Default.Map, null)
        Text("%.5f, %.5f".format(lat, pedido.longitud ?: 0.0))
    }
}
  • lastLocation, no tracking continuo: para v1 basta el punto donde se marcó la entrega.
  • Pedimos COARSE y FINE juntas: si el usuario concede «solo aproximada», seguimos funcionando.
  • La migración preserva los pedidos de sprints anteriores — verifica en Database Inspector.
Para probar: Extended Controls (… del emulador) → Location → envía un punto arbitrario (por ejemplo -12.046, -77.043, Plaza Mayor de Lima).
Teoría de apoyo: permisos en tiempo de ejecución → Manual 03 · Capítulo 11. Migraciones → Glosario · «Migration».
Checkpoint: con un pedido en camino, el botón dispara el diálogo de permisos. Concede: aparecen coordenadas reales en la ficha y el enlace abre Google Maps en ese punto. Los pedidos viejos conservan todo.

15 · Avisos y segundo plano

Sprint 15 ~30 min

Objetivo: la aplicación vigila el servidor aunque nadie la mire. WorkManager sincroniza cada 15 minutos y avisa con una notificación cuando llega un pedido nuevo.

Paso 1 · Dependencia y permiso

implementation("androidx.work:work-runtime-ktx:2.10.0")

<!-- Manifest -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Paso 2 · El trabajador

// logica/SincronizacionWorker.kt
package com.pedidos.app.logica

class SincronizacionWorker(
    contexto: Context,
    parametros: WorkerParameters
) : CoroutineWorker(contexto, parametros) {

    override suspend fun doWork(): Result {
        val dao = AppBaseDatos.obtener(applicationContext).pedidoDao()
        val sincronizador = SincronizadorPedidos(RedPedidos.api, dao)
        return when (val r = sincronizador.sincronizar()) {
            is SincronizadorPedidos.Resultado.Ok -> {
                avisarSiHayNovedad()
                Result.success()
            }
            is SincronizadorPedidos.Resultado.Fallo ->
                Result.retry()   // sin red: reintenta con backoff
        }
    }

    private suspend fun avisarSiHayNovedad() {
        // Cuenta pedidos creados en los últimos 5 minutos
        val recientes = dao.contarRecientes(
            System.currentTimeMillis() - 5 * 60_000)
        if (recientes > 0) Notificaciones.avisoNuevoPedido(
            applicationContext, recientes
        )
    }
}

Con su consulta en el DAO:

@Query("SELECT COUNT(*) FROM pedidos " +
       "WHERE creadoEn >= :desde AND estado != 'CANCELADO'")
suspend fun contarRecientes(desde: Long): Int

Paso 3 · Canal y notificación

// logica/Notificaciones.kt
object Notificaciones {

    const val CANAL_PEDIDOS = "nuevos_pedidos"

    fun preparar(contexto: Context) {
        val canal = NotificationChannel(
            CANAL_PEDIDOS, "Pedidos nuevos",
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = "Avisos cuando llega un pedido del servidor"
        }
        contexto.getSystemService(NotificationManager::class.java)
                .createNotificationChannel(canal)
    }

    fun avisoNuevoPedido(contexto: Context, cantidad: Int) {
        val intencion = Intent(contexto, MainActivity::class.java).apply {
            data = Uri.parse("pedidos://listado")
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                    Intent.FLAG_ACTIVITY_CLEAR_TOP
        }
        val pendiente = PendingIntent.getActivity(
            contexto, 0, intencion,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val aviso = NotificationCompat.Builder(contexto, CANAL_PEDIDOS)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setContentTitle("Tienes $cantidad pedido(s) nuevo(s)")
            .setContentText("Toca para revisarlos")
            .setContentIntent(pendiente)
            .setAutoCancel(true)
            .build()

        NotificationManagerCompat.from(contexto)
            .notify(1001, aviso)
    }
}

Paso 4 · Programar el ciclo

// En onCreate de MainActivity:
Notificaciones.preparar(this)

// Permiso de notificaciones (Android 13+) — pídelo al primer arranque:
if (Build.VERSION.SDK_INT >= 33) {
    lanzadorPermisos.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS))
}

// Tarea periódica: mínimo real de WorkManager son 15 minutos
val peticion = PeriodicWorkRequestBuilder<SincronizacionWorker>(
    15, TimeUnit.MINUTES
).setBackoffCriteria(
    BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS
).build()

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
    "sincronizar", ExistingPeriodicWorkPolicy.KEEP, peticion
)
  • KEEP: si la tarea ya existe no se duplica — idempotente por diseño.
  • El sistema decide CUÁNDO exactamente corre: dentro de las ventanas de mantenimiento de Doze. No esperes precisión de reloj.
  • Los datos siguen fluyendo a Room; la UI abierta se actualiza sola gracias a las consultas vivas.
Para probar sin esperar 15 min: adb shell am broadcast -a "androidx.work.diagnostics.REQUEST_DIAGNOSTICS" o simplemente agrega un OneTimeWorkRequest temporal que corra al instante.
Teoría de apoyo: WorkManager vs servicios → Glosario · «WorkManager». Doze y batería → Glosario · «Doze».
Checkpoint: concede el permiso de avisos. Agrega un pedido directo al mock (curl -X POST localhost:8080/pedidos …), pon la app en segundo plano y fuerza la tarea. Llega la notificación; al tocarla, el listado ya trae el pedido nuevo.

16 · Pruebas

Sprint 16 ~30 min

Objetivo: blindar la lógica de negocio con pruebas de unidad. Viniendo del backend ya conoces el mantra: sin red, sin base, sin Android — solo JVM rápida.

Paso 1 · Dependencias de prueba

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")

Paso 2 · Dobles hechos a mano (fakes)

Nada de librerías de mocking para empezar: un fake honesto dice más.

// app/src/test/java/com/pedidos/app/Dobles.kt
package com.pedidos.app

class ApiFalsa(
    private val respuesta: List<PedidoDto> = emptyList(),
    private val fallar: Boolean = false
) : PedidosApi {
    override suspend fun listar(): List<PedidoDto> =
        if (fallar) throw java.io.IOException("sin red") else respuesta
    override suspend fun crear(pedido: PedidoDto) = pedido
    override suspend fun cambiarEstado(id: Long, cuerpo: Map<String, String>) =
        respuesta.first { it.id == id }
            .copy(estado = cuerpo["estado"] ?: "PENDIENTE")
}

class DaoFalso : PedidoDao {
    private val tabla = mutableMapOf<Long, PedidoEntidad>()
    private var siguienteId = 1L

    override fun observarTodos() =
        flow { emit(tabla.values.sortedByDescending { it.creadoEn }) }

    override suspend fun buscarPorId(id: Long) = tabla[id]

    override fun contarActivos() =
        flow { emit(tabla.values.count { it.estado != EstadoPedido.ENTREGADO }) }

    override suspend fun guardar(pedido: PedidoEntidad): Long {
        val id = if (pedido.id == 0L) siguienteId++ else pedido.id
        tabla[id] = pedido.copy(id = id)
        return id
    }

    override suspend fun borrar(pedido: PedidoEntidad) {
        tabla.remove(pedido.id)
    }
}

Paso 3 · Las tres pruebas que valen oro

A · Fusión de la sincronización — gana el más reciente:

// SincronizadorTest.kt
@Test
fun `el servidor sobrescribe si su version es mas nueva`() = runTest {
    val dao = DaoFalso()
    dao.guardar(PedidoEntidad(
        id = 101, cliente = "Local", direccion = "x", detalle = "",
        total = 1.0, estado = EstadoPedido.PENDIENTE,
        creadoEn = 1000, fotoEvidencia = null))

    val remoto = PedidoDto(
        id = 101, cliente = "Servidor", direccion = "y", detalle = "",
        total = 2.0, estado = "EN_CAMINO", creadoEn = 2000)

    SincronizadorPedidos(ApiFalsa(listOf(remoto)), dao).sincronizar()

    assertEquals("Servidor", dao.buscarPorId(101)?.cliente)
}

@Test
fun `la version local sobrevive si es mas nueva`() = runTest {
    val dao = DaoFalso()
    dao.guardar(PedidoEntidad(
        id = 101, cliente = "Local reciente", direccion = "x", detalle = "",
        total = 1.0, estado = EstadoPedido.EN_PREPARACION,
        creadoEn = 9999, fotoEvidencia = null))

    val viejo = PedidoDto(101, "Viejo", "", "", 1.0, "PENDIENTE", 5)

    SincronizadorPedidos(ApiFalsa(listOf(viejo)), dao).sincronizar()

    assertEquals("Local reciente", dao.buscarPorId(101)?.cliente)
}

B · Máquina de estados no salta estados finales:

@Test
fun `entregado no avanza a nada`() = runTest {
    val repo = PedidosRepositorio(DaoFalso())
    val id = repo.guardar(Pedido(cliente = "A", direccion = "B",
        detalle = "", total = 10.0))
    repo.cambiarEstado(id, EstadoPedido.ENTREGADO)

    // cambiarEstado sobre un final debe ser inofensivo:
    assertEquals(EstadoPedido.ENTREGADO,
                 repo.buscar(id)?.estado)
}

C · Validación del formulario rechaza total inválido:

// FormularioTest.kt — con regla para despachadores de UI:
@get:Rule
val reglaPrincipal = MainDispatcherRule()   // setMain(UnconfinedTestDispatcher())

@Test
fun `total cero genera error y no guarda`() = runTest {
    val vm = NuevoPedidoViewModel(
        PedidosRepositorio(DaoFalso()), ApiFalsa())

    vm.escribirCampo("cliente", "Rosa")
    vm.escribirCampo("direccion", "Larco 542")
    vm.escribirCampo("total", "0")
    vm.guardar()

    assertTrue("total" in vm.estado.value.errores)
}

// MainDispatcherRule (misma carpeta test):
class MainDispatcherRule(
    private val despachador: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) =
        Dispatchers.setMain(despachador)
    override fun finished(description: Description) =
        Dispatchers.resetMain()
}
./gradlew test # toda la suite JVM
# → BUILD SUCCESSFUL · 5 pruebas pasando en ~2 s
  • Fakes > mocks aquí: el DAO falso se reusa en TODAS las pruebas y documenta el contrato.
  • Cada prueba prueba UNA decisión de negocio (fusión, estados, validación), no implementación interna.
  • La arquitectura por capas hizo esto posible: ViewModel y lógica nunca tocaron Context ni Compose.
Teoría de apoyo: pirámide de pruebas → Manual 04 · Capítulo 17. Fakes vs stubs vs mocks → Glosario · «Test doubles».
Checkpoint: ./gradlew test en verde. Rompe algo a propósito (cambia la regla de fusión a «siempre gana servidor») y mira cómo las pruebas lo detectan antes que tus usuarios.

17 · Lanzamiento v1.0

Sprint final ~30 min

Objetivo: firmar, optimizar y entregar. La aplicación sale del taller hacia un dispositivo real con HTTPS contra tu servidor.

Paso 1 · Tu propia llave

keytool -genkeypair -v \
  -keystore pedidos.jks -alias pedidos \
  -keyalg RSA -keysize 2048 -validity 10000
# Contraseña y datos los pide el asistente interactivo.
# GUARDA este archivo y su contraseña: sin ellos no hay actualizaciones.
// app/build.gradle.kts
android {
    signingConfigs {
        create("release") {
            storeFile = file("../pedidos.jks")
            storePassword = System.getenv("KS_PASS")
                ?: providers.gradleProperty("ksPass").get()
            keyAlias = "pedidos"
            keyPassword = System.getenv("KEY_PASS")
                ?: providers.gradleProperty("keyPass").get()
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = true          // R8: recorta y ofusca
            isShrinkResources = true
            signingConfig = signingConfigs.getByName("release")
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

Las contraseñas van en ~/.gradle/gradle.properties de tu máquina o en variables de entorno del CI — nunca dentro del repositorio.

Paso 2 · Adiós al tráfico claro

Elimina usesCleartextTraffic del manifiesto y apunta baseUrl a tu servidor real con HTTPS. Si tu backend usa un certificado propio (típico en redes internas), fíjalo con Network Security Config:

<!-- res/xml/red_segura.xml -->
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">pedidos.tudominio.pe</domain>
        <trust-anchors>
            <certificates src="@raw/pedidos_ca" />
        </trust-anchors>
    </domain-config>
</network-security-config>

<!-- Manifest: -->
android:networkSecurityConfig="@xml/red_segura"

Paso 3 · Versión e identidad

AjusteDónde
versionCode = 1 · versionName = "1.0.0"build.gradle.kts
Nombre visible «Pedidos»strings.xml → app_name
Ícono adaptativo naranjaImage Asset Studio sobre bi-box-seam-fill
Textos de justificación de permisosstrings.xml (ubicación, avisos)

Paso 4 · Compilar y probar en el teléfono

./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk

adb install app/build/outputs/apk/release/app-release.apk
adb shell dumpsys package com.pedidos.app | grep versionName

Lista de despacho v1.0

  • ./gradlew test verde y lint sin errores bloqueantes.
  • Ingreso real contra tu servidor por HTTPS (el mock ya se jubiló).
  • Vuelo de avión: la app abre, muestra lo guardado y sincroniza al volver la señal.
  • Foto + ubicación probadas en dispositivo físico, no solo emulador.
  • El APK firmado y su keystore respaldados en dos lugares distintos.

Lo que construyeron los 17 sprints

ParteEntregado
I · CimientosDominio, Room, migración, repositorio único
II · Primera pantallaListado vivo, gestos, detalle, navegación
III · ServidorRetrofit, offline-first, sesión con token, alta de pedidos
IV · DispositivoCámara, ubicación, WorkManager + notificaciones
V · CalidadSuite JVM, firma, R8, HTTPS fijado

De aquí en adelante cada versión nueva repite el ciclo que ya dominas: sprint corto → pruebas → checkpoint → lanzamiento. El mismo ritmo de este taller.

Teoría de apoyo: firma y publicación → Manual 03 · Capítulo 12. R8 y ofuscación → Glosario · «R8».
Checkpoint final: «Pedidos» corre en tu teléfono desde el ícono propio, entra con credenciales reales, registra una entrega completa (foto + punto de mapa) y todo llega a tu servidor. v1.0 desplegada.