https://a.storyblok.com/f/270183/37036/f5cd652c62/voice_swift-vapor_p2_1200x600.png

Criação de um aplicativo de áudio “drop-in” com SwiftUI e Vapor – Parte 2

Publicado em March 3, 2021

Tempo de leitura: 8 minutos

Introdução

A primeira parte deste tutorial utilizou a Conversation API para criar um servidor para um aplicativo de áudio instantâneo. O servidor permite criar novos usuários, criar novas salas de bate-papo e listar todas as salas de bate-papo abertas. Neste tutorial, você vai desenvolver um aplicativo para iOS que usa o Vonage Client SDK para acessar o serviço e iniciar um bate-papo. Se quiser ir direto para este tutorial, siga as instruções no repositório do GitHub do servidor para configurar tudo.

Pré-requisitos

Além disso, de acordo com os pré-requisitos da primeira parte, você precisará de o Cocoapods para instalar o Vonage Client SDK para iOS.

Criação do aplicativo para iOS

É hora de configurar o aplicativo para iOS. Depois de criá-lo, você instalará o Client SDK e solicitará permissões para o microfone.

Criar um projeto no Xcode

Para começar, abra o Xcode e crie um novo projeto acessando Arquivo > Novo > Projeto. Selecione um modelo de aplicativo e dê um nome a ele. Selecione SwiftUI como interface, “Aplicativo SwiftUI” para o ciclo de vidae Swift para a linguagem. Por fim, um local para salvar seu projeto.

Xcode project creation

Instale o Client SDK

Agora que você criou o projeto, pode adicionar o Vonage Client SDK como dependência. Navegue até o local onde salvou o projeto no seu terminal e execute os seguintes comandos.

  1. Execute o pod init para criar um novo Podfile para o seu projeto.

  2. Abra o Podfile no Xcode usando open -a Xcode Podfile.

  3. Atualize o Podfile para incluir NexmoClient como dependência.

# 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 o SDK usando pod install.

  2. Abra o novo arquivo xcworkspace no Xcode usando open SwiftUIDropin.xcworkspace.

Permissões do microfone

Como o aplicativo utilizará o microfone para fazer chamadas, é necessário solicitar permissão para isso de forma explícita.

O primeiro passo é editar o Info.plist arquivo. O Info.plist é um arquivo que contém todos os metadados necessários para o aplicativo. Adicione uma nova entrada ao arquivo passando o mouse sobre a última entrada da lista e clicando no pequeno + que aparece. Na lista suspensa, selecione Privacy - Microphone Usage Description e adicione Microphone access required to take part in audio rooms como valor.

Você realizará a segunda etapa para solicitar permissões de microfone mais adiante neste tutorial.

Criar a tela de login

O Client SDK precisa de um JWT para se conectar aos servidores da Vonage. O aplicativo para iOS precisa enviar um nome de usuário para o /auth ponto de extremidade do servidor. Crie um novo arquivo chamado Models.swift acessando Arquivo > Novo > Arquivo (CMD + N). Assim como no backend, há uma estrutura para o corpo da solicitação e outra para a resposta do servidor.

Adicione as seguintes estruturas ao Models.swift arquivo:

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

Como o aplicativo para iOS utilizará três endpoints diferentes, você criará uma pequena classe para reutilizar o código de rede. Crie um novo arquivo chamado RemoteLoader.swift e adicione a seguinte classe:

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()
    }
}

A RemoteLoader classe consiste em uma enumeração de erros e uma função estática load . A função load é genérica para dois tipos, T e U, que estão em conformidade com o Codeable protocolo.

T representa a estrutura que será usada como corpo de uma solicitação enviada por esta função. É opcional, pois algumas solicitações podem não exigir um corpo. U representa o tipo da estrutura de resposta.

Ao fazer uma solicitação de rede, você fornece a URL, o corpo da solicitação e o tipo de resposta, e a load função retorna um resultado.

Antes de começar a criar a interface do usuário (UI) do aplicativo, você criará primeiro uma classe de modelo. Essa classe é usada para separar a lógica do aplicativo do código da visualização. Nesse caso, a classe de modelo irá lidar com as chamadas de delegado do Client SDK e fazer a solicitação de rede para o login.

No início do ContentView.swift arquivo, importe o Client SDK e o AVFoundation:

import SwiftUI
import NexmoClient
import AVFoundation

Em seguida, no final do arquivo, crie uma nova classe chamada AuthModel.

final class AuthModel: NSObject, ObservableObject, NXMClientDelegate {

}

Nessa classe, defina as propriedades necessárias:

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

O @Published wrapper de propriedade é o que permite que a interface do usuário saiba quando reagir às alterações da classe do modelo; tudo isso é tratado automaticamente para você, já que a classe está em conformidade com o ObservedObject protocolo.

A audioSession propriedade é usada para solicitar permissões de microfone. Para concluir a solicitação de permissões de microfone para o aplicativo, adicione a seguinte função à AuthModel classe:

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

Essa função verificará primeiro se as permissões já foram concedidas; caso contrário, ela as solicitará e exibirá o resultado no console. Em seguida, você pode adicionar a função que faz a solicitação ao servidor backend usando a RemoteLoader classe:

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
        }
    }
}

Substitua a urlString string pela sua URL do ngrok. Assim que a resposta for recebida, a função usará o JWT para fazer login no Client SDK e definirá o delegado do Client SDK para esta classe. Em um ambiente de produção, é recomendável que o servidor também transmita informações sobre o TTL do JWT e que o aplicativo realize verificações adicionais sobre a validade do JWT antes de executar ações que exijam o Client SDK.

É NXMClientDelegate é assim que o Client SDK comunica as alterações dos servidores da Vonage de volta ao seu aplicativo. Em seguida, implemente as funções delegadas necessárias na AuthModel classe:

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
}

Quando ocorre uma alteração no status do SDK ou um erro, os valores booleanos connected e loading serão alteradas, o que provocará mudanças na interface do usuário. A função final que você precisa adicionar ao AuthModel chamadas requestPermissionsIfNeeded:

func setup() {
    requestPermissionsIfNeeded()
}

Com a classe do modelo pronta, agora você pode criar a interface do usuário. Atualize a ContentView estrutura:

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)
    }
}

A ContentView estrutura possui uma instância da AuthModel classe. A propriedade loading da authModel determinará se o ContentView ela exibirá um estado de carregamento ou a visualização de entrada, que possui um Textfield campo para a inserção de um nome de usuário e um botão que aciona a login função mencionada anteriormente.

A visualização de entrada também possui um NavigationLink que exibirá a próxima visualização, RoomListView, quando o Client SDK se conectar com sucesso. Se você comentar a NavigationLink linha e executar o projeto (CMD + R), você verá a tela de login:

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

Criar a tela da lista de salas

Quando o Client SDK se conectar com sucesso, você deverá solicitar ao servidor backend uma lista de todas as salas abertas. Da mesma forma que na tela de login, você deverá adicionar as estruturas do modelo e, em seguida, criar uma classe de modelo para lidar com a lógica. Adicione as estruturas ao Models.swift arquivo:

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
    }
}

Crie um novo arquivo chamado RoomListView.swift e adicione a classe do 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
            }
        }
    }
}

Essa classe de modelo é responsável por carregar a lista de salas e enviar a solicitação para criar uma nova sala; substitua a urlString string pela sua URL do ngrok. A interface do usuário observará a results propriedade.

Em seguida, crie a interface do usuário que observará essa classe de modelo. Adicione a RoomListView estrutura ao mesmo arquivo:

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)
        })
    }
}

A RoomListView estrutura possui um List componente que exibirá a lista de salas abertas e um botão de atualização na barra de navegação. Há também um Button componente que alterna um valor booleano que exibe uma CreateRoomModal visualização. Adicione a visualização ao mesmo arquivo:

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()
        }
    }
}

Essa visualização consiste em um TextField campo para inserção do nome da sala, um indicador de carregamento e um Button para acionar a função de criação de sala na RoomModel classe. Os dois NavigationLink componentes na RoomListView fazem referência a um RoomView. O RoomView recebe um ID de conversa como parâmetro, necessário para que o Client SDK carregue a conversa.

Criar a tela “Sala”

Esta tela final é onde os usuários do seu aplicativo participarão de conversas e se comunicarão entre si. Assim como nas telas anteriores, você começará adicionando uma estrutura de modelo ao Models.swift arquivo:

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

Essa estrutura representará como um membro de uma sala é exibido na interface do usuário. Em seguida, crie um novo arquivo chamado RoomView.swift, e dentro desse arquivo, crie uma ConversationModel classe:

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)
    }
}

A ConversationModel classe está em conformidade com NXMConversationDelegate além do ObservableObject protocolo, assim como as outras classes de modelo. As duas funções da NXMConversationDelegate que você utilizará são didReceive:event e didReceive:error.

A didReceive:event função é a forma como o aplicativo iOS será notificado sobre a saída e a entrada de usuários na conversa. Quando isso ocorre, os membros são adicionados e removidos da members array, o que a interface do usuário monitora.

Agora você pode adicionar as funções que lidam com o carregamento e o encerramento de conversas à ConversationModel classe:

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()
    }
    ...
}

As loadConversation chamadas de função getConversationWithUuid no Client SDK, retornando o objeto de conversa armazenado em uma propriedade local. Agora que você tem o objeto de conversa, pode entrar na conversa. Assim que isso for concluído, você pode habilitar a mídia para o usuário, permitindo que ele fale e ouça os outros usuários na conversa. leaveConversation faz o contrário. Ela desativa a mídia, sai da conversa e chama um manipulador de conclusão passado para a função.

A interface do usuário desta tela está dividida em duas estruturas: uma visualização menor para um único membro e uma visualização maior com uma grade de membros, que controla a navegação. Crie a MemberView estrutura no mesmo arquivo:

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

Este é um círculo com Text um campo abaixo para o nome do membro da sala. Em seguida, adicione o 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)
        })
    }
}

Essa visualização possui uma presentationMode propriedade que permitirá que a visualização seja fechada quando o usuário sair da conversa/sala, e uma grade com três colunas onde os membros da sala serão exibidos.

Execute seu aplicativo

Se você executar o projeto (CMD + R), será solicitado, primeiro, que conceda permissões para o microfone, caso ainda não tenha feito isso.

Faça login com um nome de usuário e você será direcionado para a tela da lista de salas, onde poderá criar uma sala. Ao criar uma sala, você será direcionado para a tela da sala. Repita os mesmos passos com um nome de usuário e um dispositivo diferentes, e você poderá conversar na sala!

Gif of the app flow

E agora?

Você pode encontrar o projeto completo do aplicativo para iOS em GitHub. É possível fazer muito mais com o Client SDK; saiba mais em developer.vonage.com.

Compartilhar:

https://a.storyblok.com/f/270183/400x400/19c02db2d3/abdul-ajetunmobi.png
Abdul AjetunmobiEx-funcionário da Vonage

Abdul é um Developer Advocate da Vonage. Ele tem experiência profissional na área de produtos de consumo como engenheiro de iOS. Em seu tempo livre, ele gosta de andar de bicicleta, ouvir música e orientar quem está dando os primeiros passos na área de tecnologia