Notificaciones locales
Al terminar: Programar y mostrar notificaciones locales.
Al terminar, cada gol del guion del partido de Marcador saldrá como un banner «¡Gol!» encima de la app, con el jugador, el equipo y el minuto. Tendrás además pruebas que no necesitan diálogos ni banners (un espía que anota los avisos) y una prueba de interfaz que acepta el permiso de verdad y busca el banner.
Importa porque pedir el permiso no sirve de nada si luego no se publica nada, y porque con la app en primer plano iOS no enseña una notificación salvo que se lo pidas tú. Temas: notificaciones locales y el delegado del centro de notificaciones. No instala ningún paquete.
Una notificación local, en tres piezas
Las tres piezas, en un recordatorio de riego en vez de un gol:
import UserNotifications
func recordarRiego(planta: String) {
// 1. El contenido: lo que se ve en el banner.
let contenido = UNMutableNotificationContent()
contenido.title = "Toca regar"
contenido.body = "La \(planta) tiene sed"
contenido.sound = .default // el sonido por defecto del sistema
// 2. La petición: contenido, un identificador y cuándo. trigger: nil = ahora mismo.
let peticion = UNNotificationRequest(identifier: "riego-\(planta)", content: contenido, trigger: nil)
// 3. El centro la recibe. El bloque se ejecuta al terminar; error es nil si todo fue bien.
UNUserNotificationCenter.current().add(peticion) { error in
if let error { print("no se pudo: \(error)") }
}
}El bloque de add es una closure que el sistema llama cuando termina; if let error entra solo si hubo un error. No imprime nada si todo va bien, y publicar de verdad pide el permiso y un iPhone: lo verás funcionar en el paso 1.
Una petición guarda lo que le diste, y se puede leer: peticion.identifier es su identificador, peticion.content.title y peticion.content.body son el título y el cuerpo de su contenido, y peticion.trigger es el disparador, que vale nil si se entrega ya.
Con la app delante, iOS no enseña nada
Hay un detalle que sorprende: por defecto iOS no muestra una notificación si la app está en primer plano. Para que salga hace falta un delegado: un objeto al que el sistema consulta antes de mostrarla. Se asigna a centro.delegate y responde al método willPresent.
Piezas nuevas:
UNUserNotificationCenterDelegate: el protocolo del delegado. Viene de Objective-C, y por eso la clase hereda deNSObject(lo dejaste preparado en la lección anterior).override init()ysuper.init(): una clase que hereda de otra inicializa primero a la de arriba;overridedice que reemplazas elinitque ya traeNSObject.nonisolated: el proyecto aísla todo al hilo principal por defecto (@MainActor y el hilo principal); el sistema llama awillPresentdesde otro hilo, así que esta función se marca sin aislamiento.- La firma de
willPresent, que se escribe tal cual. Devuelve las opciones de aviso:[.banner, .sound], o[]para que no se vea nada.
Todas estas piezas juntas las escribirás en el paso 2, en NotificadorDeSistema.
El espía, para probar sin banners
Un espía es un falso que solo anota lo que le piden, para preguntarlo después. Lo que guarda son tuplas con nombres: varios valores juntos, cada uno con su nombre, como (destino: String, texto: String); y map(\.destino) (un key path, la ruta a una propiedad) saca solo ese valor de cada tupla. Con un mensajero en vez de un notificador:
protocol Mensajero { func enviar(a destino: String, texto: String) }
// Un espía: cumple el protocolo y solo anota lo que le piden.
final class MensajeroEspia: Mensajero {
// Un array de tuplas con nombre: cada envío guarda dos valores con su etiqueta.
private(set) var enviados: [(destino: String, texto: String)] = []
func enviar(a destino: String, texto: String) {
enviados.append((destino, texto)) // los nombres los toma del tipo del array
}
}
let espia = MensajeroEspia()
espia.enviar(a: "Rosa", texto: "hola")
espia.enviar(a: "Leo", texto: "adiós")
print(espia.enviados.map(\.destino)) // un key path sobre cada tupla["Rosa", "Leo"]Reto · Ejercicio: el recordatorio del entreno
En tu Playground (reutiliza el mismo y borra lo anterior), escribe una función, recordatorio, que reciba el nombre de un equipo y un día y devuelva la petición de una notificación local, sin enviarla:
- título «Entreno»;
- cuerpo «Rayo FC entrena el martes», con los datos que reciba;
- el sonido por defecto;
- el identificador «entreno-» seguido del equipo;
- entrega inmediata.
Crea la del Rayo FC del martes e imprime su identificador, su título, su cuerpo y, según tenga o no disparador, «se entrega ya» o «se entrega más tarde». Lo que debe salir:
entreno-Rayo FC
Entreno
Rayo FC entrena el martes
se entrega yaVer solución
import UserNotifications
// Arma la petición de un recordatorio de entreno (no la envía: solo la construye).
func recordatorio(de equipo: String, dia: String) -> UNNotificationRequest {
let contenido = UNMutableNotificationContent()
contenido.title = "Entreno"
contenido.body = "\(equipo) entrena el \(dia)"
contenido.sound = .default
return UNNotificationRequest(identifier: "entreno-\(equipo)", content: contenido, trigger: nil)
}
let peticion = recordatorio(de: "Rayo FC", dia: "martes")
print(peticion.identifier)
print(peticion.content.title)
print(peticion.content.body)
print(peticion.trigger == nil ? "se entrega ya" : "se entrega más tarde")entreno-Rayo FC
Entreno
Rayo FC entrena el martes
se entrega yaSolo construye la petición: por eso no hace falta ningún permiso en el Playground.
Paso 1 · Avisar de un gol
Objetivo: que NotificadorDeSistema cumpla el protocolo Notificador, es decir, que sepa montar y publicar la notificación «¡Gol!».
Intenta escribirlo tú: NotificadorDeSistema pasa a cumplir el protocolo Notificador, con su función notificarGol, que recibe el minuto, el jugador y el equipo con esas etiquetas. Si no hay permiso, no hace nada; si lo hay, publica ya mismo una notificación con el título «¡Gol!», el cuerpo «Luis (Rayo FC), minuto 35» con los datos que reciba y el sonido por defecto, identificada con su minuto y su jugador (así dos goles del mismo minuto no se pisan: en el partido 1 hay dos en el minuto 10 y dos en el 35). La publicación se anota en el registro: «notificación «¡Gol!» enviada: Luis, minuto 35».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
La cabecera de la clase gana el protocolo; sustituye la línea final class NotificadorDeSistema: NSObject { por:
final class NotificadorDeSistema: NSObject, Notificador {Y la función nueva va dentro de la clase, debajo de pedirPermiso():
func notificarGol(minuto: Int, jugador: String, equipo: String) {
// Sin permiso, no hay nada que hacer.
guard permitido else { return }
// 1. El contenido: qué se ve.
let contenido = UNMutableNotificationContent()
contenido.title = "¡Gol!"
contenido.body = "\(jugador) (\(equipo)), minuto \(minuto)"
contenido.sound = .default
// 2. La petición. El identificador distingue notificaciones: con el mismo, la nueva
// sustituye a la anterior. Lleva el minuto y el jugador para que dos goles del mismo
// minuto no se pisen. `trigger: nil` = entregarla ya.
let peticion = UNNotificationRequest(identifier: "gol-\(minuto)-\(jugador)", content: contenido, trigger: nil)
// 3. El centro la recibe. El bloque se ejecuta al terminar, con el error si lo hubo.
centro.add(peticion) { error in
if let error { Registro.anotar("no se pudo publicar la notificación: \(error)") }
}
Registro.anotar("notificación «¡Gol!» enviada: \(jugador), minuto \(minuto)")
}Compila con Cmd+B.
Paso 2 · El delegado: que el banner salga con la app delante
Objetivo: que NotificadorDeSistema sea también el delegado del centro de notificaciones y pida enseñar el banner.
Un argumento de lanzamiento nuevo, -sinAvisosEnPrimerPlano YES, apaga el banner en las pruebas de interfaz (lo verás en el paso 4). Primero, en Configuracion.swift, dentro de enum Configuracion, justo encima de reiniciarDatos:
// `-sinAvisosEnPrimerPlano YES`: con la app delante, no sale banner ni suena.
// Lo pasan las pruebas de interfaz; por defecto, apagado: la app enseña el banner.
static var sinAvisosEnPrimerPlano: Bool {
UserDefaults.standard.bool(forKey: "sinAvisosEnPrimerPlano")
}Intenta escribirlo tú: NotificadorDeSistema pasa a ser también el delegado del centro de notificaciones: al crearse, después de inicializar su clase madre, se ofrece él mismo como delegado; y cuando llega una notificación con la app delante, pide enseñar el banner y sonar, salvo que el ajuste sinAvisosEnPrimerPlano esté encendido, y entonces nada.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
La cabecera pasa a declarar el delegado; sustituye la línea de la clase por:
final class NotificadorDeSistema: NSObject, Notificador, UNUserNotificationCenterDelegate {El init, justo debajo de centro:
override init() {
super.init()
// El delegado decide qué hacer con una notificación que llega CON LA APP DELANTE.
centro.delegate = self
}Y willPresent, al final de la clase:
// `nonisolated`: lo llama el sistema desde cualquier hilo.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// Con `-sinAvisosEnPrimerPlano YES` (pruebas de interfaz): ni banner ni sonido.
Configuracion.sinAvisosEnPrimerPlano ? [] : [.banner, .sound]
}Compila con Cmd+B.
Paso 3 · Que el ViewModel avise de cada gol
Objetivo: que cada gol del guion saque un banner «¡Gol!».
Pieza nueva: if case let .gol(…) = evento, el patrón de un enum con datos (Enums: el partido de eventos); solo entra si el evento es un gol.
Intenta escribirlo tú: el ViewModel recibe un notificador opcional (entre el repositorio y los milisegundos por minuto; si no se da, nadie avisa) y avisa de cada gol del guion con el minuto, el jugador y el equipo; y la pantalla se lo pasa desde el entorno. Lo que debes ver: un banner «¡Gol!» por cada gol del guion.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModel.swift: recibe el notificador (opcional, para que las pruebas que no lo necesitan no lo pasen) y avisa de cada gol del guion. La propiedad, debajo de la de reintroducirElCiclo:
// Quien avisa de los goles (nil = nadie). Es un protocolo: en las pruebas, un espía.
@ObservationIgnored private let notificador: (any Notificador)?El init del ViewModel ya recibe partidoId, repositorio y msPorMinuto (y un último parámetro para el ciclo de retención que ya conoces). El parámetro nuevo va entre repositorio y msPorMinuto:
repositorio: any PartidosRepositorio,
notificador: (any Notificador)? = nil,
msPorMinuto: Int = Configuracion.msPorMinuto,Y su asignación, en el cuerpo del init, entre self.registrarGol = … y self.msPorMinuto = …:
self.notificador = notificadorEl aviso va dentro del for que aplica los goles del guion, justo después de golesEnVivo = …:
// Cada gol del guion avisa (los «a mano» no: los puso el usuario, que ya lo sabe).
if case let .gol(minutoDelGol, jugador, equipo) = evento {
notificador?.notificarGol(minuto: minutoDelGol, jugador: jugador.nombre, equipo: equipo.nombre)
}PartidoEnDirectoView.swift: sustituye el init entero (la cabecera, la asignación, el comentario y la llave que cierra) por este:
init(partidoId: Int, repositorio: any PartidosRepositorio, notificador: any Notificador) {
_viewModel = State(wrappedValue: PartidoViewModel(partidoId: partidoId, repositorio: repositorio, notificador: notificador))
}Hay dos vías para el mismo objeto: el init lo recibe por parámetro porque el ViewModel se crea ahí y el entorno aún no se puede leer; el @Environment que pusiste en la lección anterior es para el body (leer notificador.permitido y pedir el permiso). El entorno se busca por la clase NotificadorDeSistema misma, no por el protocolo; el ViewModel, en cambio, solo conoce el protocolo Notificador.
Y en PartidoContenedor, que crea la vista, otro @Environment (aquí sí se puede leer, en una propiedad) y el argumento nuevo, con el que la vista recibe por el init lo que sacó del entorno:
@Environment(NotificadorDeSistema.self) private var notificador PartidoEnDirectoView(partidoId: id, repositorio: repositorio, notificador: notificador)Probarlo
Ejecuta la app desde Xcode (Cmd+R) en un iPhone del simulador y abre un partido: arranca solo. Si diste el permiso en la lección anterior, el botón ya dice Avisos de gol: activados y cada gol del guion saca un banner «¡Gol!» con el jugador, el equipo y el minuto. En el partido 1 los goles caen en los minutos 10, 35 y 60, con dos en el 10 y dos en el 35. En la consola de Xcode (Cmd+Shift+Y) verás, entre otras, estas líneas:
notificación «¡Gol!» enviada: Ana, minuto 10
notificación «¡Gol!» enviada: Iván, minuto 10
notificación «¡Gol!» enviada: Luis, minuto 35
notificación «¡Gol!» enviada: Sofía, minuto 35Si es la primera vez que pides el permiso (por ejemplo, borraste la app del simulador), pulsa Activar avisos de gol y acepta: el diálogo te lleva unos segundos, así que lo normal es que el primer banner que veas sea el del minuto 35, Luis (Rayo FC), minuto 35. El momento exacto depende de lo que tardes en tocar.
Paso 4 · Pruebas
Objetivo: que el ViewModel se pruebe sin diálogos ni banners, con un espía, y una prueba de interfaz que acepta el permiso de verdad y busca el banner.
Intenta escribir tú el espía y dos pruebas del ViewModel, en PartidoViewModelTests.swift:
NotificadorEspia, que cumpleNotificadorcon el permiso dado y solo anota, en orden, cada aviso que recibe con su minuto, su jugador y su equipo (desde fuera se leen, pero solo él los añade).cadaGolDelGuionAvisaAlNotificador: jugar entero el partido 1 con minutos de 0 ms avisa de los minutos 12, 55 y 80, de Ana, Iván y Ana, y el primero es del Rayo FC.losGolesAManoNoAvisanYLosPosterioresALaDuracionTampoco: un gol a mano del local no avisa, y un partido de 60 minutos solo avisa de los minutos 12 y 55.
Los minutos 12, 55 y 80 no son los de la pantalla (10, 35 y 60): las pruebas del ViewModel usan el partido de ejemplo del paquete Torneo, que tiene sus propios goles.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModelTests.swift: el espía va arriba del fichero, entre los import y el @MainActor (es un falso que anota lo que le piden). El resto del fichero no cambia:
// El espía: un falso que solo anota lo que le piden (el ViewModel no sabe que no es el de verdad).
final class NotificadorEspia: Notificador {
var permitido = true
// Cada aviso recibido, en orden; `private(set)`: solo cambia desde aquí.
private(set) var avisos: [(minuto: Int, jugador: String, equipo: String)] = []
func notificarGol(minuto: Int, jugador: String, equipo: String) {
avisos.append((minuto, jugador, equipo))
}
}Las dos pruebas van al final de PartidoViewModelTests, antes de la última llave:
// Cada gol del guion avisa: goles de Ana (12), Iván (55) y Ana (80).
@Test func cadaGolDelGuionAvisaAlNotificador() async {
let espia = NotificadorEspia()
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), notificador: espia, msPorMinuto: 0)
await viewModel.jugar(duracion: 90)
#expect(espia.avisos.map(\.minuto) == [12, 55, 80])
#expect(espia.avisos.map(\.jugador) == ["Ana", "Iván", "Ana"])
#expect(espia.avisos.first?.equipo == "Rayo FC")
}
// Los goles «a mano» no avisan, y un partido de 60 minutos no avisa del gol del minuto 80.
@Test func losGolesAManoNoAvisanYLosPosterioresALaDuracionTampoco() async {
let espia = NotificadorEspia()
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), notificador: espia, msPorMinuto: 0)
viewModel.alGol(.local)
#expect(espia.avisos.isEmpty)
await viewModel.jugar(duracion: 60)
#expect(espia.avisos.map(\.minuto) == [12, 55])
}Un detalle antes de las pruebas de interfaz: el permiso concedido persiste en el simulador. Con el permiso dado, cada gol saca un banner, las pruebas esperan a que la app esté quieta antes de cada acción y una de unos 11 segundos puede tardar mucho más. Por eso todas pasan -sinAvisosEnPrimerPlano YES, que willPresent ya lee; solo la de notificaciones lo deja sin él, porque comprueba el banner.
Primero, una ayuda: Ayudas.swift gana sinAvisos (la lista con ese argumento de lanzamiento) y capturarPantalla (una captura de la pantalla entera del dispositivo, con los banners del sistema, que la de la app no ve). Se da hecho con el botón Copiar fichero, que copia el contenido del fichero al portapapeles (no escribe nada en tu proyecto): ábrelo en el Project navigator (doble clic), pulsa el botón, haz clic dentro del editor, selecciona todo con Cmd+A y pega con Cmd+V.
Ahora la prueba de interfaz que acepta el diálogo y busca el banner. Se da hecha, porque usa piezas que no están en la tabla de XCUITest (waitForExistence, tap y las demás de siempre sí):
XCUIApplication(bundleIdentifier: "com.apple.springboard"): SpringBoard es la pantalla de inicio del sistema. El diálogo del permiso y el banner los dibuja ella, no tu app, así que se habla con ella.springboard.alerts.buttons["Permitir"]: un botón dentro de un diálogo del sistema. El texto depende del idioma del simulador («Allow» en inglés), por eso unforprueba los dos ybreaksale delforen cuanto uno ha funcionado.launchArguments: los argumentos con los que arranca la app, como en Ajustes que sobreviven.XCTAssertTrue(condición, "mensaje"): lo mismo que ya usas, con el texto que se verá si falla.capturarPantalla("permiso"): la ayuda que acabas de copiar enAyudas.swift.
La prueba arranca con minutos de 50 ms y con -reiniciarDatos YES (sin sinAvisos, porque aquí el banner es lo que se comprueba), abre el partido 1, pulsa «Activar avisos de gol», acepta el diálogo (con una captura mientras se ve), espera el botón «Avisos de gol: activados», cambia la duración a 60 min (eso relanza el partido, y los goles del minuto 10 vuelven a ocurrir), comprueba que sale el texto «¡Gol!» y hace otra captura. Si ya habías concedido el permiso, no sale el diálogo y la prueba sigue igual: el if lo contempla.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En MarcadorAppUITests.swift, dentro de MarcadorAppUITests, justo encima de la prueba testLosDatosSobrevivenAReiniciarLaApp (si esa prueba lleva un comentario y @MainActor encima, antes de ellos):
// Pide el permiso de notificaciones (diálogo del sistema) y, con él, los goles del minuto 10 sacan un banner.
@MainActor
func testUnGolSacaUnaNotificacion() throws {
let app = XCUIApplication()
// Esta es la ÚNICA prueba que no pasa `sinAvisos`: aquí el banner es justo lo que se comprueba.
app.launchArguments = ["-msPorMinuto", "50", "-reiniciarDatos", "YES"]
app.launch()
app.buttons["partido-1"].tap()
let activar = app.buttons["Activar avisos de gol"]
if activar.waitForExistence(timeout: 5) {
activar.tap()
// El diálogo lo dibuja SpringBoard (otra app), no la nuestra.
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
for texto in ["Permitir", "Allow"] {
let boton = springboard.alerts.buttons[texto]
if boton.waitForExistence(timeout: 3) {
capturarPantalla("permiso")
boton.tap()
break // ya está aceptado: no hace falta probar el otro texto
}
}
}
XCTAssertTrue(app.buttons["Avisos de gol: activados"].waitForExistence(timeout: 5))
// Cambiar la duración relanza el partido (`.task(id:)`) y los goles del minuto 10 vuelven a ocurrir.
app.buttons["60 min"].tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let banner = springboard.staticTexts["¡Gol!"]
XCTAssertTrue(banner.waitForExistence(timeout: 10), "No salió el banner de «¡Gol!»")
capturarPantalla("notificacion")
}Faltan los cambios mecánicos: sinAvisos en las otras 13 pruebas de MarcadorAppUITests.swift, todas menos la nueva. En cada una, busca la línea que arranca la app (app.launch()) y mira si la prueba pasa argumentos. Hay dos casos.
Si arranca con app.launch() a secas, gana una línea justo antes:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// antes
let app = XCUIApplication()
app.launch()
// después
let app = XCUIApplication()
app.launchArguments = sinAvisos
app.launch()Si ya trae una lista de argumentos (app.launchArguments = [...]), le suma sinAvisos con un +, que une dos listas en una (testLosDatosSobrevivenAReiniciarLaApp arranca la app dos veces: cámbiala las dos):
// antes
app.launchArguments = ["-msPorMinuto", "60"]
// después
app.launchArguments = ["-msPorMinuto", "60"] + sinAvisosLa última, MarcadorAppUITestsLaunchTests.swift, es la prueba de arranque, con sinAvisos: se da hecha igual que Ayudas.swift.
Tras estos cambios, el Test navigator (Cmd+6) lista 14 pruebas en MarcadorAppUITests. Cmd+U lanza todas las pruebas, también las de interfaz (tardan más y abren el simulador). Para lanzar solo las unitarias, en el Test navigator pulsa el play junto a MarcadorAppTests, o usa la Terminal; y la de interfaz, en solitario:
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests test
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppUITests/MarcadorAppUITests/testUnGolSacaUnaNotificacion testLos dos terminan con ** TEST SUCCEEDED **, que es lo que sale siempre. Las demás líneas (como Executed 0 tests, que no es un fallo) cambian según se ejecuten o no las pruebas en paralelo, como cuenta Tus primeras pruebas en iOS. xcodebuild reutiliza el simulador: el permiso concedido sigue ahí en la ejecución siguiente. Si tu simulador se llama distinto, mira su nombre con xcrun simctl list devices available.
Punto de control
- Con el permiso concedido, cada gol del guion saca un banner «¡Gol!» y el registro muestra
notificación «¡Gol!» enviada: …(con Ana e Iván en el minuto 10). - El
Info.plistde la app instalada (elplutil -pde la lección anterior) sigue sin ninguna clave de notificaciones. -only-testing:MarcadorAppTeststermina con** TEST SUCCEEDED **y la prueba de interfaztestUnGolSacaUnaNotificaciontambién.- Cmd+U da 31 pruebas en verde: 16 unitarias y 15 de interfaz (14 en
MarcadorAppUITestsytestLaunch).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Con el permiso concedido y la app en primer plano, llega una notificación local y no se ve ningún banner. ¿Qué falta?
2.
PartidoViewModelrecibe unNotificador(un protocolo) en vez de usarUNUserNotificationCenterdirectamente. ¿Qué se gana?3.En una prueba de interfaz, el diálogo de permisos y el banner «¡Gol!» se buscan en
XCUIApplication(bundleIdentifier: "com.apple.springboard")y no en la app de Marcador. ¿Por qué?4.¿Para qué pasan
sinAvisostodas las pruebas de interfaz salvotestUnGolSacaUnaNotificacion?5.Con el identificador
"gol-\(minuto)"publicas la notificación de Ana (minuto 10) y justo después la de Iván (minuto 10). ¿Qué pasa y cómo se arregla?