https://d226lax1qjow5r.cloudfront.net/blog/blogposts/building-a-drop-in-audio-app-with-swiftui-and-vapor-part-2/voice_swift-vapor_p2_1200x600.png

Creación de una aplicación de audio con SwiftUI y Vapor - Parte 2

Publicado el March 3, 2021

Tiempo de lectura: 8 minutos

Introducción

En primera parte de este tutorial utilizó la Conversation API para crear un servidor para una aplicación de audio drop-in. El servidor admite la creación de nuevos usuarios, la creación de nuevas salas de chat y el listado de todas las salas de chat abiertas. En este tutorial, crearás una aplicación iOS que utiliza el Vonage Client SDK para consumir y comenzar a chatear. Si quieres saltar directamente a este tutorial, puedes seguir las instrucciones en el repositorio de GitHub del servidor para configurarlo todo.

Requisitos previos

Además, de los requisitos previos de la primera parte, necesitará Cocoapods para instalar el Client SDK de Vonage para iOS.

Creación de la aplicación iOS

Es hora de crear la aplicación para iOS. Una vez creada, instalará el Client SDK y pedirá permisos para el micrófono.

Crear un proyecto Xcode

Para empezar, abra Xcode y cree un nuevo proyecto accediendo a Archivo > Nuevo > Proyecto. Seleccione una Plantilla de aplicación y dele un nombre. Seleccione SwiftUI para la interfaz interfazSwiftUI App para el ciclo de viday Swift para el lenguaje. Por último, una ubicación para guardar el proyecto.

Xcode project creation

Instalar el Client SDK

Ahora que has creado el proyecto, puedes agregar el Vonage Client SDK como dependencia. Navega a la ubicación donde guardaste el proyecto en tu terminal y ejecuta los siguientes comandos.

  1. Ejecute el comando pod init para crear un nuevo Podfile para su proyecto.

  2. Abra el Podfile en Xcode utilizando open -a Xcode Podfile.

  3. Actualiza el Podfile para que tenga NexmoClient como dependencia.

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'SwiftUIDropin' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for SwiftUIDropin
  pod 'NexmoClient'
end
  1. Instale el SDK con pod install.

  2. Abra el nuevo xcworkspace en Xcode con open SwiftUIDropin.xcworkspace.

Permisos de micrófono

Dado que la aplicación utilizará el micrófono para realizar llamadas, es necesario pedir permiso explícitamente para hacerlo.

El primer paso es editar el archivo Info.plist archivo. El Info.plist es un archivo que contiene todos los metadatos necesarios para la aplicación. Añada una nueva entrada al archivo pasando el ratón por encima de la última entrada de la lista y haga clic en el pequeño botón + que aparece. En la lista desplegable, seleccione Privacy - Microphone Usage Description y añada Microphone access required to take part in audio rooms como valor.

El segundo paso para solicitar permisos de micrófono lo harás más adelante en el tutorial.

Crear la pantalla de inicio de sesión

El Client SDK necesita un JWT para conectarse a los servidores de Vonage. La aplicación iOS necesita enviar un nombre de usuario al /auth endpoint del servidor. Crea un nuevo archivo llamado Models.swift yendo a Archivo > Nuevo > Archivo (CMD + N). Similar al backend, hay un struct para el cuerpo de la petición y un struct para la respuesta del servidor.

Añada los siguientes structs al archivo Models.swift archivo:

struct Auth: Codable {
    struct Body: Codable {
        let name: String
    }
    
    struct Response: Codable {
        let name: String
        let jwt: String
    }
}

Dado que la aplicación iOS utilizará tres endpoints diferentes, crearás una pequeña clase para reutilizar el código de red. Crea un nuevo archivo llamado RemoteLoader.swift y añade la siguiente clase:

import Foundation

final class RemoteLoader {
    enum RemoteLoaderError: Error {
        case url
        case data
    }
    
    static func load<T: Codable, U: Codable>(urlString: String, body: T?, responseType: U.Type, completion: @escaping ((Result<U, RemoteLoaderError>) -> Void)) {
        guard let url = URL(string: urlString) else {
            completion(.failure(.url))
            return
        }
        
        var request = URLRequest(url: url)
        
        if let body = body, let encodedBody = try? JSONEncoder().encode(body) {
            request.httpMethod = "POST"
            request.httpBody = encodedBody
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data {
                if let response = try? JSONDecoder().decode(U.self, from: data) {
                    completion(.success(response))
                    return
                }
            }
            completion(.failure(.data))
        }.resume()
    }
}

La clase RemoteLoader consta de un enum de error y una función estática load estática. La función de carga es genérica sobre dos tipos T y Uque se ajustan al Codeable protocolo.

T representa la estructura que se utilizará como cuerpo de la petición que envíe esta función. Es opcional ya que algunas peticiones pueden no requerir un cuerpo. U representa el tipo de estructura de respuesta.

Cuando se realiza una solicitud de red, se proporciona la URL, el cuerpo y el tipo de respuesta, y la función load devuelve un resultado.

Antes de empezar a construir la interfaz de usuario (UI) para la aplicación, construirás primero una clase modelo. Esta clase se utiliza para separar la lógica de la aplicación del código de la vista. En este caso, la clase modelo gestionará las llamadas delegadas del Client SDK y realizará la solicitud de inicio de sesión en la red.

En la parte superior del archivo ContentView.swift importe el Client SDK y AVFoundation:

import SwiftUI
import NexmoClient
import AVFoundation

A continuación, en la parte inferior del archivo, crear una nueva clase llamada AuthModel.

final class AuthModel: NSObject, ObservableObject, NXMClientDelegate {

}

Dentro de esta clase, defina las propiedades necesarias:

final class AuthModel: NSObject, ObservableObject, NXMClientDelegate {
    @Published var loading = false
    @Published var connected = false
    
    var name = ""
    
    private let audioSession = AVAudioSession.sharedInstance()
}

La envoltura de la propiedad @Published es la forma en que la interfaz de usuario sabrá cuándo reaccionar a los cambios de la clase modelo; todo esto se gestiona por ti, ya que la clase se ajusta al protocolo ObservedObject protocolo.

La propiedad audioSession se utiliza para solicitar los permisos del micrófono. Para completar la solicitud de permisos de micrófono para la aplicación, añada la siguiente función a la clase AuthModel clase:

func requestPermissionsIfNeeded() {
    if audioSession.recordPermission != .granted {
        audioSession.requestRecordPermission { (isGranted) in
            print("Microphone permissions \(isGranted)")
        }
    }
}

Esta función comprobará primero si los permisos ya han sido concedidos; si no, los solicitará e imprimirá el resultado en la consola. A continuación, puedes añadir la función que realiza la petición al servidor backend utilizando la clase RemoteLoader clase:

func login() {
    loading = true
    
    RemoteLoader.load(urlString: "https://URL.ngrok.io/auth", body: Auth.Body(name: self.name), responseType: Auth.Response.self) { result in
        switch result {
        case .success(let response):
            DispatchQueue.main.async {
                NXMClient.shared.setDelegate(self)
                NXMClient.shared.login(withAuthToken: response.jwt)
            }
        default:
            break
        }
    }
}

Sustituya la cadena urlString por su URL ngrok. Una vez recibida la respuesta, la función utilizará el JWT para iniciar sesión en el Client SDK y establecer el delegado del SDK en esta clase. En un entorno de producción, querrás que el servidor también pase información sobre el TTL del JWT y realice comprobaciones adicionales en la aplicación sobre la validez del JWT antes de realizar acciones que requieran el Client SDK.

El NXMClientDelegate es la forma en que el Client SDK comunica los cambios con los servidores de Vonage a tu aplicación. Luego, implementa las funciones delegadas requeridas en la clase AuthModel clase:

func client(_ client: NXMClient, didChange status: NXMConnectionStatus, reason: NXMConnectionStatusReason) {
    switch status {
    case .connected:
        self.connected = true
        self.loading = false
    default:
        self.connected = false
        self.loading = false
    }
}

func client(_ client: NXMClient, didReceiveError error: Error) {
    self.loading = false
    self.connected = false
}

Cuando se produce un cambio en el estado del SDK o un error, los booleanos connected y loading cambiarán, lo que provocará cambios en la interfaz de usuario. La última función que necesitas añadir al módulo AuthModel llama a requestPermissionsIfNeeded:

func setup() {
    requestPermissionsIfNeeded()
}

Con la clase modelo completa, ahora puedes crear la interfaz de usuario. Actualiza la estructura ContentView struct:

struct ContentView: View {
    @ObservedObject var authModel = AuthModel()
    
    var body: some View {
        NavigationView {
            VStack {
                if authModel.loading {
                    ProgressView()
                    Text("Loading").padding(20)
                } else {
                    TextField("Name", text: $authModel.name)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .multilineTextAlignment(.center)
                        .padding(20)
                    Button("Log in") {
                        authModel.login()
                    }
                    NavigationLink("", destination: RoomListView(),
                                   isActive: $authModel.connected).hidden()
                    
                }
            }.navigationTitle("VonageHouse 👋")
            .navigationBarBackButtonHidden(true)
        }.onAppear(perform: authModel.setup)
    }
}

La estructura ContentView struct tiene una instancia de la AuthModel clase La propiedad loading de la estructura authModel determinará si el ContentView muestra un estado de carga o la vista de entrada, que tiene un botón Textfield para introducir un nombre de usuario y un botón que activa la función login de antes.

La vista de entrada también tiene un botón oculto NavigationLink que empujará la siguiente vista, RoomListViewcuando el Client SDK se conecte correctamente. Si comenta la línea NavigationLink y ejecutas el proyecto (CMD + R), verás la pantalla de inicio de sesión:

Two screenshots, the first the iOS app requesting permissions, the second the login screen.

Crear la pantalla de lista de salas

Cuando el Client SDK se conecte correctamente, deberá solicitar al servidor backend que obtenga una lista de todas las salas abiertas. De manera similar a la pantalla de inicio de sesión, usted querrá agregar los structs del modelo y luego construir una clase modelo para manejar la lógica. Añade los structs al archivo Models.swift archivo:

struct RoomResponse: Codable {
    let id: String
    let displayName: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case displayName = "display_name"
    }
}

struct CreateRoom: Codable {
    struct Body: Codable {
        let displayName: String
        
        enum CodingKeys: String, CodingKey {
            case displayName = "display_name"
        }
    }
    
    struct Response: Codable {
        let id: String
    }
}

Crea un nuevo archivo llamado RoomListView.swift y añade la clase modelo:

import SwiftUI

final class RoomModel: ObservableObject {
    @Published var results = [RoomResponse]()
    @Published var loading = false
    @Published var showingCreateModal = false
    @Published var hasConv = false
    
    var convID: String? = nil
    var roomName: String = ""
    
    func loadRooms() {
        RemoteLoader.load(urlString: "https://URL.ngrok.io/rooms", body: Optional<String>.none, responseType: [RoomResponse].self) { result in
            switch result {
            case .success(let response):
                DispatchQueue.main.async {
                    self.results = response
                }
            default:
                break
            }
        }
    }
    
    func createRoom() {
        RemoteLoader.load(urlString: "https://URL.ngrok.io/rooms", body: CreateRoom.Body(displayName: self.roomName), responseType: CreateRoom.Response.self) { result in
            switch result {
            case .success(let response):
                self.convID = response.id
                DispatchQueue.main.async {
                    self.hasConv = true
                    self.loading = false
                    self.showingCreateModal = false
                }
            default:
                break
            }
        }
    }
}

Esta clase modelo se encarga de cargar la lista de salas y enviar la solicitud para crear una nueva sala; sustituya la cadena urlString por la URL de ngrok. La interfaz de usuario observará la propiedad results propiedad.

A continuación, crea la interfaz de usuario que observará esta clase modelo. Añade la estructura RoomListView struct al mismo archivo:

struct RoomListView: View {
    @ObservedObject var roomModel = RoomModel()
    
    var body: some View {
        VStack {
            List(roomModel.results, id: \.id) { item in
                VStack(alignment: .leading) {
                    NavigationLink(destination: RoomView(convID: item.id, convName: item.displayName)) {
                        Text(item.displayName)
                    }
                }
            }.onAppear(perform: roomModel.loadRooms)
            Button("Create room") {
                roomModel.showingCreateModal.toggle()
            }
            NavigationLink("", destination: RoomView(convID: roomModel.convID ?? "", convName: roomModel.roomName),
                           isActive: $roomModel.hasConv).hidden()
        }
        .navigationTitle("VonageCottage 👋")
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(trailing:
                                Button("Refresh") {
                                    roomModel.loadRooms()
                                }
        )
        .sheet(isPresented: $roomModel.showingCreateModal, content: {
            CreateRoomModal(roomModel: roomModel)
        })
    }
}

La estructura RoomListView struct tiene un componente List que mostrará la lista de salas abiertas y un botón de actualización en la barra de navegación. También hay un componente Button componente, que activa un booleano que muestra una CreateRoomModal vista. Añade la vista al mismo archivo:

struct CreateRoomModal: View {
    @ObservedObject var roomModel: RoomModel
    
    var body: some View {
        if !roomModel.loading {
            VStack {
                TextField("Enter the room name", text: $roomModel.roomName)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .multilineTextAlignment(.center)
                    .padding(20)
                Button("Create room") {
                    roomModel.loading = true
                    roomModel.createRoom()
                }
            }
        } else {
            ProgressView()
        }
    }
}

Esta vista consta de un TextField para introducir el nombre de la sala, un estado de carga y un estado Button para activar la función de creación de sala en la clase RoomModel clase. Los dos componentes NavigationLink de la vista RoomListView referencia a RoomView. El RoomView toma un ID de conversación como parámetro que el Client SDK necesita para cargar la conversación.

Crear la pantalla de sala

Esta última pantalla es donde los usuarios de tu aplicación se unirán a las conversaciones y hablarán entre ellos. Al igual que en las pantallas anteriores, empezarás añadiendo una estructura de modelo al archivo Models.swift archivo:

struct Member: Hashable {
    let id: String
    let name: String
}

Esta estructura representará cómo se muestra un miembro de una sala en la interfaz de usuario. A continuación, cree un nuevo archivo llamado RoomView.swifty, dentro de él, crea una clase ConversationModel clase:

import SwiftUI
import NexmoClient

final class ConversationModel: NSObject, ObservableObject, NXMConversationDelegate {
    @Published var loading = false
    @Published var members = [Member]()
    
    private var conversation: NXMConversation?
    private let currentUsername: String? = NXMClient.shared.user?.name
        
    func memberFrom(_ event: NXMMemberEvent) -> Member {
        return Member(id: event.fromMemberId, name: event.embeddedInfo?.user.name ?? "")
    }

    func memberFrom(_ nxmMemberSummary: NXMMemberSummary) -> Member {
         return Member(id: nxmMemberSummary.memberUuid, name: nxmMemberSummary.user.name)
     }
    
    func conversation(_ conversation: NXMConversation, didReceive event: NXMMemberEvent) {
        let member = memberFrom(event)
        switch event.state {
        case .joined:
            guard !self.members.contains(member),
                  self.currentUsername != member.name else { break }
            self.members.append(member)
        case .left:
            guard self.members.contains(member),
                  let memberIndex = self.members.firstIndex(of: member) else { break }
            self.members.remove(at: memberIndex)
        default:
            break
        }
    }
    
    func conversation(_ conversation: NXMConversation, didReceive error: Error) {
        print(error.localizedDescription)
    }
}

La clase ConversationModel se ajusta a NXMConversationDelegate además del protocolo ObservableObject como las demás clases modelo. Las dos funciones del NXMConversationDelegate que utilizarás son didReceive:event y didReceive:error.

La función didReceive:event es la forma en que la aplicación de iOS será notificada de los usuarios que abandonan y se unen a la conversación. Cuando esto ocurre, se añaden y eliminan miembros del array members que observa la interfaz de usuario.

Ahora puedes añadir las funciones que gestionan la carga y la salida de las conversaciones a la clase ConversationModel clase:

final class ConversationModel: NSObject, ObservableObject, NXMConversationDelegate {
    ...
    func loadConversation(convID: String) {
        guard conversation == nil else { return }
        
        loading = true
        NXMClient.shared.getConversationWithUuid(convID) { error, conversation in
            self.conversation = conversation
            self.conversation?.delegate = self

            self.conversation?.join { [weak self] error, memberId in
                guard let self = self else { return }
                self.conversation?.getMembersPage(withPageSize: 100, order: .asc) { error, membersPage in
                    DispatchQueue.main.async {
                        guard let membersPage = membersPage else { return }
                        self.members = membersPage.memberSummaries.map { self.memberFrom($0) }

                        if !self.members.contains(where: { $0.name == self.currentUsername }) {
                            if let id = memberId, let name = self.currentUsername {
                                self.members.append(Member(id: id, name: name))
                            }
                        }
                        self.loading = false
                    }
                    
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        self.conversation?.enableMedia()
                    }
                }
            }
        }
    }
    
    func leaveConversation(completion: () -> Void) {
        self.conversation?.disableMedia()
        self.conversation?.leave(nil)
        completion()
    }
    ...
}

La función loadConversation función llama getConversationWithUuid en el Client SDK, devolviendo el objeto de conversación almacenado en una propiedad local. Ahora que tienes el objeto de conversación, puedes unirte a la conversación. Una vez completado esto, puedes habilitar los medios para el usuario, permitiéndole hablar y escuchar a otros usuarios en la conversación. leaveConversation hace lo contrario. Desactiva los medios, abandona la conversación y llama a un controlador de finalización pasado a la función.

La interfaz de usuario para esta pantalla se divide en dos estructuras, una vista más pequeña para un solo miembro y una vista más grande con una cuadrícula de miembros y maneja la navegación. Cree la estructura MemberView en el mismo archivo:

struct MemberView: View {
    var memberName: String
    
    var body: some View {
        VStack {
            Circle()
                .fill(Color.gray)
                .frame(width: 75, height: 75)
            Text(memberName)
        }
    }
}

Se trata de un círculo con Text para el nombre del miembro de la sala. A continuación, añada RoomView:

struct RoomView: View {
    @StateObject var conversationModel = ConversationModel()
    @Environment(\.presentationMode) var presentationMode
    
    var convID: String
    var convName: String
    
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    var body: some View {
        VStack {
            if conversationModel.loading {
                ProgressView()
                Text("Loading").padding(20)
            } else {
                VStack {
                    ScrollView {
                        LazyVGrid(columns: columns, spacing: 75) {
                            ForEach(conversationModel.members, id: \.self) { member in
                                MemberView(memberName: member.name)
                            }
                        }
                    }
                    Button("Leave room") {
                        conversationModel.leaveConversation(completion: { presentationMode.wrappedValue.dismiss() })
                    }
                }
            }
        }.navigationTitle(convName)
        .navigationBarBackButtonHidden(true)
        .onAppear(perform: {
            conversationModel.loadConversation(convID: convID)
        })
    }
}

Esta vista tiene una propiedad presentationMode que permitirá salir de la vista cuando el usuario abandone la conversación/sala, y una cuadrícula de tres columnas de ancho donde se mostrarán los miembros de la sala.

Ejecute su aplicación

Si ejecutas el proyecto (CMD + R), primero se te pedirá que autorices los permisos de micrófono si aún no lo has hecho.

Inicie sesión con un nombre de usuario y accederá a la pantalla de la lista de salas, donde podrá crear una sala. Al crear una sala, accederás a la vista de sala. Repite los mismos pasos con un nombre de usuario y un dispositivo diferentes, ¡y podrás chatear en la sala!

Gif of the app flow

¿Y ahora qué?

Puede encontrar el proyecto de aplicación iOS completo en GitHub. Puedes hacer mucho más con el Client SDK, aprende más en developer.vonage.com.

Compartir:

https://a.storyblok.com/f/270183/400x400/19c02db2d3/abdul-ajetunmobi.png
Abdul AjetunmobiVonage Antiguo miembro del equipo

Abdul es desarrollador de Vonage. Ha trabajado en productos de consumo como ingeniero de iOS. En su tiempo libre, le gusta andar en bicicleta, escuchar música y asesorar a aquellos que están comenzando su viaje en la tecnología.