Saltar al contenido
K
Jetpack y arquitecturaPaso a paso35 min de lectura · 10 min de máquina Básico

DataStore en Marcador: la duración del partido

Al terminar: Guardar la duración del partido con DataStore y usarla en el directo de Marcador.

Al terminar esta lección, Marcador tiene una preferencia guardada en el móvil: la duración del partido, de 90 o de 60 minutos. La eliges con dos botones, el partido en vivo termina donde toca y, si cierras la app del todo y la abres otra vez, la elección sigue ahí.

Importa porque es el uso real de lo que practicaste con la reserva de mesa: un dato suelto que debe sobrevivir a cerrar la app. Es la segunda mitad de DataStore: aquella dejó la librería instalada y el ejemplo funcionando; esta la aplica a Marcador. Temas: una preferencia detrás de una interfaz, constructor secundario y cómo llega la duración hasta el reloj del partido.

Paso · la duración del partido

Objetivo. Con la librería ya instalada, dos botones («90 min» y «60 min») en la pantalla del partido. Con «60 min», el partido acaba en el minuto 60 y el botón dice «Partido terminado». La elección se guarda y sobrevive a cerrar la app.

app → kotlin+java → com.sebastiannarvaez.marcador
di
DatosModule.ktse modifica
feature
marcador
domain
Preferencias.ktse crea
Directo.ktse modifica
datapaquete nuevo
PreferenciasDataStore.ktse crea
ui
Efectos.ktse modifica
MarcadorUiState.ktse modifica
PantallaMarcador.ktse modifica
PartidoViewModel.ktse modifica
Dos ficheros se crean y seis se modifican, todos dentro de app → kotlin+java.

Crear los ficheros. En la vista Android, clic derecho sobre com.sebastiannarvaez.marcador → New → Package; el campo ya trae com.sebastiannarvaez.marcador: escribe .feature.marcador.data al final (con el punto delante). data es la parte de la feature que sabe dónde se guardan las cosas, y la preferencia es del marcador. Después, clic derecho sobre data → New → Kotlin Class/File → elige File y escribe PreferenciasDataStore. Para Preferencias, clic derecho sobre el nodo domain de esa misma feature → New → Kotlin Class/File → File y escribe Preferencias. Android Studio abre cada fichero con la línea package ya escrita.

Los bloques de abajo llevan como título la ruta del fichero en tu proyecto (app/src/main/java/…); en la vista Android cada fichero está en app → kotlin+java, dentro del paquete que dice su package.

Cómo encaja

El guion es la lista de eventos programados del partido de ejemplo (los goles con su minuto): el Directo los va aplicando a medida que corre el reloj. Con 60 minutos, los eventos posteriores al 60 se descartan con filter.

PiezaQué hace
PreferenciasRepository (dominio)Dice qué: un Flow con la duración y una función para cambiarla
PreferenciasDataStore (datos)Dice cómo: con DataStore (feature/marcador/data)
DatosModule (Hilt)Tiene la receta para construirla y se la da a quien la pida
PartidoViewModelLa recibe por el constructor, la lee al cargar el partido (first()) y la guarda al pulsar un botón
DirectoRecibe los minutos: ignora los eventos del guion posteriores y su reloj acaba ahí

Por qué la preferencia es una receta de DatosModule. PreferenciasDataStore necesita un Context para abrir su fichero, y Hilt no sabe cuál darle si no se lo dices: hay varios (el de la Application y el de cada Activity). Por eso se escribe una receta con @Provides que pide el Context de la Application con @ApplicationContext (el mismo que usa la base de datos) y llama con él al constructor de la clase. Lleva @Singleton para que toda la app comparta una sola. Y fíjate en lo que no hay que hacer: el PartidoViewModel gana un parámetro en su constructor y nada más. Hilt ya sabe construirlo y, al ver el parámetro nuevo, busca la receta que acabas de escribir. Añadir una dependencia es una línea en el constructor y, si Hilt no sabe construirla, una receta en el módulo.

Qué cambia en los ficheros que ya existen

Estos cambios son obligatorios y van fuera del bloque escondido; el código de cada uno está dentro.

FicheroQué cambia
DatosModule.ktUna receta nueva, preferencias, con @Provides y @Singleton
Directo.ktEl parámetro partido pasa a llamarse guion; minutosDelPartido deja de ser privado (el ViewModel lo lee); y aparece una propiedad partido nueva, calculada al crear el Directo con los eventos del guion hasta esa duración. El resto de la clase sigue usando partido: ahora es esa propiedad, ya recortada
MarcadorUiState.ktExito gana un campo duracion, y MarcadorEvento gana CambiarDuracion(minutos)
Efectos.ktPanelDelCronometro recibe duracion, y el botón compara el minuto con ella en lugar de con MINUTOS_DEL_PARTIDO
PartidoViewModel.ktRecibe las preferencias en el constructor (Hilt las pasa solo), atiende CambiarDuracion y lee la duración guardada al cargar el partido
PantallaMarcador.ktPasa duracion al panel y muestra la fila de dos chips

Intenta escribirlo tú

Piezas nuevas de este paso:

  • preferencesDataStore, intPreferencesKey, data y edit { }: en DataStore.
  • FilterChip(selected, onClick, label): el chip de la reserva de mesa.
  • constructor(contexto: Context) : this(…): un constructor secundario, una segunda forma de crear la clase; this(…) llama al constructor primario con los argumentos que calcula. Aquí uno recibe un DataStore y el otro un Context.

Ya vistas: require (Clases y objetos), first() (Flow frío), copy (data class e igualdad), companion object (Herencia, interfaces y composición) y Unit en una rama de when (Capas en Marcador: el directo).

Esas piezas, con otra preferencia inventada (el tamaño de la letra, no la duración del partido):

ejemplo mínimo: una preferencia detrás de una interfaz (con comentarios)
interface LetraRepository {
    val tamano: Flow<Int>                                      // lo que cambia: un Flow
    suspend fun cambiar(puntos: Int)                           // lo que ocurre una vez: suspend
 
    companion object {                                         // constantes ligadas a la interfaz
        val TAMANOS = listOf(14, 18)                           // se lee LetraRepository.TAMANOS
        const val TAMANO_POR_DEFECTO = 14
    }
}
 
private val Context.lectura: DataStore<Preferences> by preferencesDataStore(name = "lectura")  // una sola vez por fichero
 
class LetraDataStore(private val almacen: DataStore<Preferences>) : LetraRepository {
    constructor(contexto: Context) : this(contexto.applicationContext.lectura)  // constructor secundario: llama al primario con this(…)
 
    private val clave = intPreferencesKey("tamano_de_letra")
 
    override val tamano: Flow<Int> = almacen.data.map { it[clave] ?: LetraRepository.TAMANO_POR_DEFECTO }
 
    override suspend fun cambiar(puntos: Int) {
        require(puntos in LetraRepository.TAMANOS) { "Tamaño no permitido: $puntos" }  // lanza si la condición es falsa
        almacen.edit { it[clave] = puntos }
    }
}
 
// En un ViewModel: leer el valor de ahora una sola vez
viewModelScope.launch {
    val puntos = letra.tamano.first()                          // first(): espera el primer valor y lo devuelve
    estilo.value = estilo.value.copy(puntos = puntos)          // copy: el mismo objeto con un campo cambiado
}
 
// En la pantalla: un chip por opción
LetraRepository.TAMANOS.forEach { puntos ->
    FilterChip(
        selected = actual == puntos,                           // marcado si es el elegido
        onClick = { onCambiar(puntos) },                       // avisa hacia arriba
        label = { Text("$puntos pt") },
    )
}

Intenta escribirlo tú (la dependencia ya la instalaste en DataStore), en este orden:

  1. En feature.marcador.domain, la interfaz PreferenciasRepository: da la duración del partido como un flujo, permite cambiarla, y guarda como constantes las duraciones permitidas, 90 y 60, y la de por defecto, 90.
  2. En feature.marcador.data, PreferenciasDataStore, que la implementa con un DataStore llamado ajustes y la clave duracion_del_partido: si no hay nada guardado da 90, y si le piden otra duración que no sea 90 ni 60, falla con el mensaje «Duración no permitida: 45». Se puede crear con un DataStore o con un Context.
  3. Los cambios de la tabla de arriba, en su orden: la receta de DatosModule, Directo, MarcadorUiState, Efectos y el ViewModel, que al cambiar la duración la guarda y recarga el partido.
  4. En la pantalla, una fila con un chip por duración («90 min» y «60 min»), marcado el de la duración actual.

Debes ver los dos botones y, con «60 min», un partido que acaba en el minuto 60.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/Preferencias.kt
package com.sebastiannarvaez.marcador.feature.marcador.domain
 
import kotlinx.coroutines.flow.Flow
 
interface PreferenciasRepository {
    val duracionDelPartido: Flow<Int>
 
    suspend fun cambiarDuracion(minutos: Int)
 
    companion object {
        val DURACIONES = listOf(90, 60)
        const val DURACION_POR_DEFECTO = 90
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/data/PreferenciasDataStore.kt
package com.sebastiannarvaez.marcador.feature.marcador.data
 
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
 
private val Context.ajustes: DataStore<Preferences> by preferencesDataStore(name = "ajustes")
 
class PreferenciasDataStore(private val almacen: DataStore<Preferences>) : PreferenciasRepository {
 
    constructor(contexto: Context) : this(contexto.applicationContext.ajustes)
 
    private val claveDuracion = intPreferencesKey("duracion_del_partido")
 
    override val duracionDelPartido: Flow<Int> = almacen.data.map { preferencias ->
        preferencias[claveDuracion] ?: PreferenciasRepository.DURACION_POR_DEFECTO
    }
 
    override suspend fun cambiarDuracion(minutos: Int) {
        require(minutos in PreferenciasRepository.DURACIONES) { "Duración no permitida: $minutos" }
        almacen.edit { it[claveDuracion] = minutos }
    }
}

El constructor secundario (constructor(contexto: Context)) es el que usa la app; el primario recibe un DataStore ya construido, y es el que llama el secundario.

app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
import com.sebastiannarvaez.marcador.feature.marcador.data.PreferenciasDataStore
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepository

Los dos import van con los demás, en orden alfabético.

app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
    @Provides
    @Singleton
    fun preferencias(@ApplicationContext contexto: Context): PreferenciasRepository = PreferenciasDataStore(contexto)

Va dentro de DatosModule, después de partidoDao. El resto del fichero no cambia.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/Directo.kt
class Directo(
    val partidoId: Int,
    guion: Partido,
    private val msPorMinuto: Long,
    val minutosDelPartido: Int = 90,
) {
    val partido: Partido = guion.copy(eventos = guion.eventos.filter { it.minuto <= minutosDelPartido })

Sustituye la cabecera de la clase, desde class Directo( hasta ) {, y añade debajo la línea val partido: antes partido era un parámetro y ahora es esta propiedad, así que el resto de la clase sigue funcionando sin tocarlo. guion.copy(eventos = …) es el copy de las data class (data class e igualdad): un Partido igual que guion pero con otra lista de eventos. El resto del fichero no cambia: el bucle de iniciar ya paraba en minutosDelPartido.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorUiState.kt
        val duracion: Int = 90,

Va al final de los campos de Exito, después de val ultimoAviso: String,.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorUiState.kt
    data class CambiarDuracion(val minutos: Int) : MarcadorEvento

Va con los demás eventos de MarcadorEvento, después de Elegir.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
    duracion: Int = MINUTOS_DEL_PARTIDO,

Es un parámetro nuevo de PanelDelCronometro, después de onEmpezar.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
            Text(if (minuto >= duracion) "Partido terminado" else if (corriendo) "En juego" else "Empezar partido")

Sustituye la línea del texto del botón, que comparaba con MINUTOS_DEL_PARTIDO.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepository

Va junto a los demás import de feature.marcador.domain.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
import kotlinx.coroutines.flow.first

Va con los import de kotlinx.coroutines.flow, después de flatMapLatest.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    private val preferencias: PreferenciasRepository,

Es el cuarto parámetro del constructor de PartidoViewModel, después de registrarGol. No hay nada más que cambiar para que le llegue: Hilt busca una receta para PreferenciasRepository y encuentra la de DatosModule.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
                    duracion = paso.directo.minutosDelPartido,

Va al final de la llamada a MarcadorUiState.Exito(…), después de ultimoAviso = instante.ultimoAviso,.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        is MarcadorEvento.CambiarDuracion -> {
            viewModelScope.launch {
                preferencias.cambiarDuracion(evento.minutos)
                cargar(partidoId.value)
            }
            Unit
        }

Es una rama nueva del when de alEvento, después de la de Elegir. Guarda la preferencia y recarga el partido; Unit en la última línea porque todas las ramas de ese when devuelven Unit y launch devuelve un Job.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
                carga.value = Carga.Lista(Directo(id, elegido, MS_POR_MINUTO, preferencias.duracionDelPartido.first()))

Sustituye la línea de cargar que crea el Directo: ahora lee la duración guardada.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.compose.material3.FilterChip

Va con los demás import de androidx.compose.material3, en orden alfabético.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepository

Va con los import de com.sebastiannarvaez.marcador, antes de los de torneo.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
                            duracion = estado.duracion,

Va al final de la llamada a PanelDelCronometro(…), después de onEmpezar = ….

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
                        Text("Duración del partido (preferencia guardada)")
                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                            PreferenciasRepository.DURACIONES.forEach { minutos ->
                                FilterChip(
                                    selected = estado.duracion == minutos,
                                    onClick = { onEvento(MarcadorEvento.CambiarDuracion(minutos)) },
                                    label = { Text("$minutos min") },
                                )
                            }
                        }

Va justo después de la llamada a PanelDelCronometro(…), dentro de la lambda extras = { … }. El resto del fichero no cambia.

Cómo verlo funcionar

Ejecuta la app con el botón Run, con app en el selector de configuración y la variante gratisDebug activa (View → Tool Windows → Build Variants, como en Gradle: flavors y variantes). El emulador es el que ya usas desde La Activity.

  1. En la pestaña Marcador, elige 60 min y pulsa Empezar partido. Al cambiar la duración, el partido se recarga y el minuto vuelve a 0': es lo esperado, porque CambiarDuracion guarda la preferencia y vuelve a cargar el partido.
  2. El minuto avanza cada 250 milisegundos, así que el partido llega al minuto 60 en unos 15 segundos. Verás «Minuto 60'» y el botón dice «Partido terminado». Con 90 min tardaría hasta el minuto 90.
  3. Cierra la app del todo. En la Terminal de Android Studio, adb shell am force-stop com.sebastiannarvaez.marcador.debug hace que Android la pare (am es el gestor de actividades; el identificador es el de la variante gratisDebug). Si prefieres no usar la Terminal, en el emulador abre la lista de apps recientes y desliza la de Marcador hacia arriba. Después ábrela desde el cajón de aplicaciones. Sin tocar nada, el chip «60 min» sigue seleccionado y el partido vuelve a acabar en el minuto 60: la preferencia persistió.

Punto de control

  • En la Terminal de Android Studio, ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL.
  • ./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspath lista androidx.datastore:datastore-preferences:1.2.1.
  • En la pestaña Marcador aparecen dos chips, «90 min» y «60 min», con el de 90 seleccionado la primera vez.
  • Con «60 min» elegido, el partido acaba en el minuto 60 y el botón dice «Partido terminado».
  • Tras cerrar la app (con adb shell am force-stop com.sebastiannarvaez.marcador.debug o deslizándola fuera de recientes) y abrirla, la elección se conserva.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.PreferenciasRepository está en domain y PreferenciasDataStore en data. ¿Qué se gana con esa separación?

  2. 2.PreferenciasDataStore valida con require(minutos in DURACIONES). ¿Qué pasa si alguien pide cambiarDuracion(45)?

  3. 3.Al pulsar «60 min», el minuto del partido vuelve a 0'. ¿Por qué?

  4. 4.Con 60 minutos, ¿cómo se descartan los eventos del guion posteriores al minuto 60?

  5. 5.Al añadir private val preferencias: PreferenciasRepository al constructor de PartidoViewModel, ¿qué más hay que escribir para que le llegue?

Faltan 5 de 5