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.
1 · Preparar el terreno
Sprint 1 ~15 minObjetivo 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
)
}
}
}
}
}setContent y el BOM →
Manual 04 · Capítulo 2. Anatomía de la plantilla generada → Manual 03 · Capítulo 1.2 · El modelo de datos
Sprint 2 ~15 minObjetivo: 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 = 0como centinela de «nuevo»: Room autogenerará el real.- Fechas como
Long(epoch millis): trivial de ordenar y persistir. - Campo
fotoEvidenciadesde 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)
)
}Log.d) que
DatosPrueba.pedidos().size == 3.3 · Persistencia con Room
Sprint 3 ~20 minObjetivo: 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")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()) }
}
}pedidos.db — verifica con App Inspection →
Database Inspector en Android Studio.4 · El repositorio
Sprint 4 ~15 minObjetivo: 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 3Por qué esta capa vale oro
| Sprint futuro | Cambio necesario |
|---|---|
| 9 · Retrofit | Añadir métodos de red AQUÍ; nadie más se entera |
| 10 · Sincronización | Lógica servidor↔Room encapsulada aquí |
| 16 · Pruebas | Inyectar un repositorio falso es trivial |
5 · Listado estático
Sprint 5 ~20 minObjetivo: 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 */ })
}
}6 · Estado con ViewModel
Sprint 6 ~20 minObjetivo: 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,
_estadopara 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) }
}
}
}
}
}collectAsStateWithLifecycle → Manual 03 · Capítulo 3.
combine → Manual 01 (flujos).7 · Acciones en el listado
Sprint 7 ~20 minObjetivo: 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».
SwipeToDismissBox →
Manual 04 · Capítulo 15. Snackbars y eventos → Manual 04 · Capítulo 10.8 · Detalle y navegación
Sprint 8 ~25 minObjetivo: 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.
keyen el viewModel evita reusar un VM de otro pedido al navegar rápido.avanzarEstadorelee de Room tras escribir: una sola verdad.- Estados finales deshabilitan la acción — la máquina de estados manda.
9 · Contrato con el API
Sprint 9 ~25 minObjetivo: 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/pedidosPaso 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)
}
}10 · Sincronización
Sprint 10 ~25 minObjetivo: 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ón | Quién manda |
|---|---|
| Lectura en pantalla | Siempre Room (instantáneo, funciona sin red) |
| Nuevos datos del servidor | Escriben en Room; la UI se entera sola |
| Fallo de red | No es error visible: es un aviso amable |
SharedFlow para eventos únicos → Glosario ·
«StateFlow vs SharedFlow».11 · Ingreso y sesión
Sprint 11 ~30 minObjetivo: 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.jsPaso 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")
}
}
}
}
}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") }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 minObjetivo: 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:
formularioValidoes 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.
13 · Evidencia fotográfica
Sprint 13 ~30 minObjetivo: 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") // AsyncImageEn 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.
14 · Ubicación del reparto
Sprint 14 ~30 minObjetivo: 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 classesaDominio() 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.
15 · Avisos y segundo plano
Sprint 15 ~30 minObjetivo: 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): IntPaso 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.
adb shell am broadcast -a
"androidx.work.diagnostics.REQUEST_DIAGNOSTICS" o simplemente agrega un
OneTimeWorkRequest temporal que corra al instante.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 minObjetivo: 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.
./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 minObjetivo: 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
| Ajuste | Dónde |
|---|---|
versionCode = 1 · versionName = "1.0.0" | build.gradle.kts |
| Nombre visible «Pedidos» | strings.xml → app_name |
| Ícono adaptativo naranja | Image Asset Studio sobre bi-box-seam-fill |
| Textos de justificación de permisos | strings.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 versionNameLista de despacho v1.0
./gradlew testverde ylintsin 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
| Parte | Entregado |
|---|---|
| I · Cimientos | Dominio, Room, migración, repositorio único |
| II · Primera pantalla | Listado vivo, gestos, detalle, navegación |
| III · Servidor | Retrofit, offline-first, sesión con token, alta de pedidos |
| IV · Dispositivo | Cámara, ubicación, WorkManager + notificaciones |
| V · Calidad | Suite 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.