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.
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.
| Pieza | Qué 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 |
PartidoViewModel | La recibe por el constructor, la lee al cargar el partido (first()) y la guarda al pulsar un botón |
Directo | Recibe 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.
| Fichero | Qué cambia |
|---|---|
DatosModule.kt | Una receta nueva, preferencias, con @Provides y @Singleton |
Directo.kt | El 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.kt | Exito gana un campo duracion, y MarcadorEvento gana CambiarDuracion(minutos) |
Efectos.kt | PanelDelCronometro recibe duracion, y el botón compara el minuto con ella en lugar de con MINUTOS_DEL_PARTIDO |
PartidoViewModel.kt | Recibe las preferencias en el constructor (Hilt las pasa solo), atiende CambiarDuracion y lee la duración guardada al cargar el partido |
PantallaMarcador.kt | Pasa duracion al panel y muestra la fila de dos chips |
Intenta escribirlo tú
Piezas nuevas de este paso:
preferencesDataStore,intPreferencesKey,datayedit { }: 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 unDataStorey el otro unContext.
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):
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:
- En
feature.marcador.domain, la interfazPreferenciasRepository: 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. - En
feature.marcador.data,PreferenciasDataStore, que la implementa con un DataStore llamadoajustesy la claveduracion_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 unContext. - Los cambios de la tabla de arriba, en su orden: la receta de
DatosModule,Directo,MarcadorUiState,Efectosy el ViewModel, que al cambiar la duración la guarda y recarga el partido. - 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
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
}
}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.
import com.sebastiannarvaez.marcador.feature.marcador.data.PreferenciasDataStore
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepositoryLos dos import van con los demás, en orden alfabético.
@Provides
@Singleton
fun preferencias(@ApplicationContext contexto: Context): PreferenciasRepository = PreferenciasDataStore(contexto)Va dentro de DatosModule, después de partidoDao. El resto del fichero no cambia.
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.
val duracion: Int = 90,Va al final de los campos de Exito, después de val ultimoAviso: String,.
data class CambiarDuracion(val minutos: Int) : MarcadorEventoVa con los demás eventos de MarcadorEvento, después de Elegir.
duracion: Int = MINUTOS_DEL_PARTIDO,Es un parámetro nuevo de PanelDelCronometro, después de onEmpezar.
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.
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepositoryVa junto a los demás import de feature.marcador.domain.
import kotlinx.coroutines.flow.firstVa con los import de kotlinx.coroutines.flow, después de flatMapLatest.
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.
duracion = paso.directo.minutosDelPartido,Va al final de la llamada a MarcadorUiState.Exito(…), después de ultimoAviso = instante.ultimoAviso,.
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.
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.
import androidx.compose.material3.FilterChipVa con los demás import de androidx.compose.material3, en orden alfabético.
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepositoryVa con los import de com.sebastiannarvaez.marcador, antes de los de torneo.
duracion = estado.duracion,Va al final de la llamada a PanelDelCronometro(…), después de onEmpezar = ….
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.
- 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
CambiarDuracionguarda la preferencia y vuelve a cargar el partido. - 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.
- Cierra la app del todo. En la Terminal de Android Studio,
adb shell am force-stop com.sebastiannarvaez.marcador.debughace que Android la pare (ames el gestor de actividades; el identificador es el de la variantegratisDebug). 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:assembleGratisDebugtermina enBUILD SUCCESSFUL. ./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspathlistaandroidx.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.debugo 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.
PreferenciasRepositoryestá endomainyPreferenciasDataStoreendata. ¿Qué se gana con esa separación?2.
PreferenciasDataStorevalida conrequire(minutos in DURACIONES). ¿Qué pasa si alguien pidecambiarDuracion(45)?3.Al pulsar «60 min», el minuto del partido vuelve a 0'. ¿Por qué?
4.Con 60 minutos, ¿cómo se descartan los eventos del guion posteriores al minuto 60?
5.Al añadir
private val preferencias: PreferenciasRepositoryal constructor dePartidoViewModel, ¿qué más hay que escribir para que le llegue?