Implementando fluxo de login com o Navigation no Jetpack Compose

Implementando fluxo de login com o Navigation no Jetpack Compose

Introdução

Na maioria dos Apps, é muito comum termos uma tela de autenticação que recebe as informações para entrar no App (login e senha) e uma tela de registro que permite cadastrar novos usuários para conseguir autenticar. Esse tipo de implementação também é conhecida como fluxo de login ou autenticação.

O funcionamento de todo o mecanismo exige uma combinação de implementação, desde as telas, lógica de cadastro e, principalmente, a navegação entre as telas. Dados esses pontos, neste artigo vamos aprender como implementar um fluxo de autenticação com o Navigation do Jetpack Compose. Em outras palavras, não vamos explorar o fluxo de cadastro de usuário.

É válido ressaltar que o fluxo de autenticação pode variar em sua complexidade, portanto, o exemplo que abordaremos aqui focará apenas no fluxo de navegação entre a tela de autenticação e tela inicial do App.

Banner da Escola de Mobile: Matricula-se na escola de Mobile. Junte-se a uma comunidade de mais de 500 mil estudantes. Na Alura você tem acesso a todos os cursos em uma única assinatura; tem novos lançamentos a cada semana; desafios práticos. Clique e saiba mais!

Projeto a ser utilizado

Para abordamos o tema de uma forma bem prática, iremos utilizar o App Panucci como exemplo, um projeto Android desenvolvido no curso de Navigation no Jetpack Compose. Conheça como ele funciona, no gif abaixo:

App Panucci em execução realizando a navegação das telas. Primeiro a tela de destaque, menu e bebidas a partir de uma barra de app inferior, depois a navegação da tela de detalhes ao clicar em um dos itens de produto a partir da tela de destaque. Por fim, é acessada a tela de pedido ao clicar no botão pedir a partir da tela de detalhes do produto.

Para conferir mais detalhes do projeto, você pode acessar o repositório no GitHub. Também, vamos utilizar um composable para representar a tela de autenticação que vai receber o usuário e senha:

  • AuthenticationScreen
Tela de autenticação com a mensagem “welcome to Panucci”, dois campos de texto para inserir o usuário e senha e dois botões para entrar no app e registrar.

O código da tela está disponível no repositório do GitHub.

Agora que conhecemos o projeto prático, vamos começar a implementação de fato!

Adicionando os destinos no grafo de navageção

Como primeiro passo, você precisa registrar as telas no grafo de navegação que está na MainActivity:

NavHost(
    navController = navController,
    startDestination = AppDestination.Highlight.route
) {
    composable(AppDestination.Authentication.route) {
        AuthenticationScreen()
    }
    ...
}

Neste projeto, é utilizado o padrão de constantes do AppDestination para determinar as rotas de navegação, então a implementação de AppDestination.Authentication fica da seguinte maneira:

sealed class AppDestination(val route: String) {
    object Highlight : AppDestination("highlight")
    object Menu : AppDestination("menu")
    object Drinks : AppDestination("drinks")
    object ProductDetails : AppDestination("productDetails")
    object Checkout : AppDestination("checkout")
    object Authentication : AppDestination("authentication")
}

Entendendo a lógica para o fluxo de login

Com o destino declarado, é preciso determinar a lógica utilizada para definir qual tela deve ser apresentada ao rodar o App. Para fazer isso, é necessário uma reflexão sobre os seguintes pontos:

  • A tela de autenticação deve ser apresentada:
    • Ao entrar no App pela primeira vez;
    • Ao entrar no App sem usuário autenticado.
  • O destino inicial deve ser apresentado:
    • Ao autenticar usuário;
    • Ao entrar no App com usuário autenticado.

Observe que, para ambos os casos, precisamos de uma técnica para armazenar uma informação se o usuário está ou não autenticado. Sendo assim, podemos utilizar uma ferramenta de persistência de dados recomendada no Android, o DataStore.

Com o DataStore, temos a capacidade de armazenar informações primitivas como strings, inteiros, booleanos etc. Dessa forma, usamos essas informações para sinalizar se um usuário foi ou não autenticado; é isso que vamos ensinar você a seguir.

Adicionando o DataStore no projeto Android

Para utilizar o DataStore, você precisa adicionar a lib como dependência no build.gradle do módulo app:

dependencies {
    ...
    implementation("androidx.datastore:datastore-preferences:1.0.0")
}

E após fazer a sincronização, você pode fazer a primeira configuração para utilizar o DataStore. Basicamente, adicione uma função de extensão em um arquivo dentro do projeto. Você pode definir onde preferir, mas, nesse exemplo, deixarei no arquivo AppPreferences.kt no pacote preferences:

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore

val Context.dataStore: DataStore<Preferences>
        by preferencesDataStore(name = "login")

Agora o DataStore é acessível em todo o projeto a partir de um Context. Vamos para a próxima etapa!

Configurando o fluxo inicial de navegação

Dado que o fluxo inicial do App é apresentar a tela de destaques do dia, é preciso ter a certeza de que o usuário está autenticado. Para confirmar isso, podemos utilizar o dataStore que foi configurado dentro do destino. Para fazer a leitura no DataStore, é necessário o seguinte código:

composable(AppDestination.Highlight.route) {
        val userPreferences = stringPreferencesKey("usuario_logado")
        val context = LocalContext.current
        var user: String? by remember {
        mutableStateOf(null)
    }
    LaunchedEffect(null) {
        user = context.dataStore.data.first()[userPreferences]
    }

    HighlightsListScreen(
        ...
    )
}

Em resumo, o código acima faz o seguinte:

  • Pega uma chave de Preferences para o tipo String;
  • Utiliza o contexto disponível do composable para poder usar o DataStore;
  • Declara um estado do tipo String? que vai permitir identificar se o usuário foi autenticado ou não;
  • Usa o LaunchedEffect(null) para rodar o código de coroutines apenas uma vez:
    • Tenta buscar o usuário a partir do DataStore utilizando o first() da API do Flow e atribui o valor encontrado

Em outras palavras, com essa configuração, a variável user irá determinar o que se deve fazer no fluxo de navegação.

Você pode, por exemplo, adicionar uma condição para navegar para a tela de login caso for nulo; caso contrário, que apresenta o conteúdo da tela de destaques:

user?.let {
    HighlightsListScreen(
        products = sampleProducts,
        onNavigateToDetails = { product ->
            navController.navigate(
                "${AppDestination.ProductDetails.route}/${product.id}"
            )
        },
        onNavigateToCheckout = {
            navController.navigate(AppDestination.Checkout.route)
        },
    )
} ?: LaunchedEffect(null) {
    navController.navigate(AppDestination.Authentication.route) {
                popUpTo(navController.graph.findStartDestination().id) {
                inclusive = true
            }
    }
}

Qualquer navegação em código de composição deve ser feito com o uso da API de Effect.

Apenas essa verificação não é o suficiente! Pois o resultado imediato do user será sempre nulo inicialmente! Em outras palavras, o App sempre vai voltar para a tela de autenticação, mesmo que exista um valor armazenado.

Sendo assim, é preciso aplicar uma técnica que permita identificar se a leitura já foi feita, para então verificar se existe o usuário ou não e realizar a ação esperada. Vamos ver como fazer isso.

Estado do dado: técnica que verifica se o usuário já foi carregado

A técnica para verificar se o usuário foi carregado - que podemos utilizar - é o estado do dado, ou seja, criamos mais uma variável que servirá como uma flag ou sinalizador do usuário. Basicamente, ela vai determinar estados esperados para tomar uma ação, por exemplo, o estados de carregamento e o de finalização.

Para determinar o estado, podemos recorrer a várias técnicas, desde tipos primitivos como inteiros ou strings até enums ou sealed objects. Para simplificar o exemplo, vou considerar o uso de string:

var dataState by remember {
    mutableStateOf("loading")
}
LaunchedEffect(null) {
    user = context.dataStore.data.first()[userPreferences]
    dataState = "finished"
}

Então, podemos utilizar o dataState junto com um when para determinar o conteúdo que será exibido com base no estado:

composable(AppDestination.Highlight.route) {
        val userPreferences = stringPreferencesKey("usuario_logado")
    val context = LocalContext.current
        var user: String? by remember {
          mutableStateOf(null)
      }
    var dataState by remember {
            mutableStateOf("loading")
        }
    LaunchedEffect(null) {
        user = context.dataStore.data.first()[userPreferences]
        dataState = "finished"
    }
    when (dataState) {
        "loading" -> {
            Box(modifier = Modifier.fillMaxSize()) {
                    Text(
                        text = "Carregando...",
                        Modifier
                            .fillMaxWidth()
                            .align(Alignment.Center),
                        textAlign = TextAlign.Center
                    )
                }
        }
        "finished" -> {
            user?.let {
                HighlightsListScreen(
                    products = sampleProducts,
                    onNavigateToDetails = { product ->
                        navController.navigate(
                            "${AppDestination.ProductDetails.route}/${product.id}"
                        )
                    },
                    onNavigateToCheckout = {
                        navController.navigate(AppDestination.Checkout.route)
                    },
                )
            } ?: LaunchedEffect(null) {
                navController.navigate(AppDestination.Authentication.route) {
                    popUpTo(navController.graph.findStartDestination().id) {
                        inclusive = true
                    }
                }
            }
        }
    }
}

Ao rodar o App, deve ser apresentada a tela de autenticação, e também, todo o grafo de navegação deve ser removido da back stack, fazendo com que a tela de autenticação seja o único destino:

Entrando no App Panucci e apresentado a tela de autenticação. Ao fazer a navegação de App, volta para a tela inicial do dispositivo.

Você pode validar esse comportamento analisando o logcat que faz a impressão da back stack atual:

I  onCreate: back stack - [null, authentication]

Ou então, pode apenas fazer uma navegação de volta e verificar se o App fecha.

Um outro teste interessante é adicionar um delay() antes do first() para visualizar o conteúdo de carregamento antes de entrar na tela de autenticação:

LaunchedEffect(null) {
    val randomMillis = Random.nextLong(500, 1000)
    delay(randomMillis)
    user = context.dataStore.data.first()[userPreferences]
    dataState = "finished"
}

Para o teste, pode ser um intervalo de um a meio segundo:

App Panucci em execução, apresenta a tela inicial indicando carregamento em alguns segundos, e então, navega para a tela de autenticação.

Observe que os componentes do Scaffold ainda são visíveis, pois o NavHost faz parte do conteúdo do Scaffold, portanto, você poderia considerar o estado do dado como mais um parâmetro para determinar se deve exibir ou não os componentes do Scaffold.

Agora que a tela inicial foi configurada, você pode começar a implementação para autenticar o usuário.

Indicando a autenticação do usuário

Para autenticar o usuário com o DataStore, você precisa apenas salvar alguma informação em String, pois é o tipo de dado que vamos usar para esse exemplo, portanto, podemos pegar o usuário a partir do campo de texto da tela de autenticação e salvar no DataStore:

composable(AppDestination.Authentication.route) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    AuthenticationScreen(
        onEnterClick = { user ->
            val userPreferences = stringPreferencesKey("usuario_logado")
            scope.launch {
                context.dataStore.edit {
                    it[userPreferences] = user
                }
            }
        }
    )
}

Um detalhe importante é que a chave de preferência precisa ser exatamente a mesma! Neste caso, o ideal é criá-la da mesma forma que se faz com o DataStore no arquivo AppPreferences.kt:

val Context.dataStore: DataStore<Preferences>
        by preferencesDataStore(name = "login")
val userPreferences = stringPreferencesKey("usuario_logado")

Com esse ajuste, você pode apenas usar o userPreferences, seja na escrita ou leitura. Concluído esse passo, vamos ao próximo.

Então, após salvar o usuário no DataStore, você precisa navegar para a tela inicial considerando a mesma técnica de limpeza da back stack:

composable(AppDestination.Authentication.route) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    AuthenticationScreen(
        onEnterClick = { user ->
            scope.launch {
                context.dataStore.edit {
                    it[userPreferences] = user
                }
            }
                        navController.navigate(AppDestination.Highlight.route) {
                        popUpTo(navController.graph.id)
                    }
            }
        }
    )
}

Pronto, isso é suficiente para rodar o App e manter a tela esperada com o usuário autenticado:

App Panucci em execução, abre a tela de autenticação primeiro, digita um usuário, no exemplo o ‘alex’, clica em ‘enter’ e abre a tela inicial do App. Ao fechar o App e abrir novamente, apresenta a tela inicial ao invés da tela de autenticação.

A seguir, precisamos adicionar uma opção para que as pessoas consigam sair do aplicativo.

Adicionando a opção para sair do App

Dado que o usuário foi autenticado uma vez, não é mais possível simular o comportamento do fluxo de autenticação, pois o usuário foi salvo no DataStore e não temos nenhuma funcionalidade que remova-o.

Sendo assim, vamos adicionar o comportamento para deslogar. Para adicionar esse comportamento, podemos reagir a um evento de clique de um elemento visual, por exemplo, um menu na top app bar, portanto, vamos implementá-lo:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PanucciApp(
    bottomAppBarItemSelected: BottomAppBarItem = bottomAppBarItems.first(),
    onBottomAppBarItemSelectedChange: (BottomAppBarItem) -> Unit = {},
    onFabClick: () -> Unit = {},
    onLogout: () -> Unit = {},
    isShowTopBar: Boolean = false,
    isShowBottomBar: Boolean = false,
    isShowFab: Boolean = false,
    content: @Composable () -> Unit
) {
    Scaffold(
        topBar = {
            if (isShowTopBar) {
                CenterAlignedTopAppBar(
                    title = {
                        Text(text = "Ristorante Panucci")
                    },
                    actions = {
                        IconButton(onClick = onLogout) {
                            Icon(
                                Icons.Filled.ExitToApp,
                                contentDescription = "sair do app"
                            )
                        }
                    }
                )
            }
        },
        ...
    ) {
        Box(
            modifier = Modifier.padding(it)
        ) {
            content()
        }
    }
}

Então, ajuste a MainActivity para poder utilizar o DataStore no PanucciApp:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val context = LocalContext.current
            val scope = rememberCoroutineScope()
            val navController = rememberNavController()
                        ...
            PanucciTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ...
                    PanucciApp(
                        ...
                        onLogout = {
                            scope.launch {
                                context.dataStore.edit {
                                    it.remove(userPreferences)
                                }
                            }
                            navController.navigate(AppDestination.Authentication.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    inclusive = true
                                }
                            }
                        }
                    ) {
                        NavHost(
                            navController = navController,
                            startDestination = AppDestination.Highlight.route
                        ) {
                                                        ...
                                                }                            
                    }
                }
            }
        }
    }

}

Com esse ajuste, o App apresenta o menu que, ao ser clicado, volta para a tela de login e mantém o fluxo esperado quando o usuário não é autenticado:

App Panucci em execução, ao entrar na tela inicial aparece o ícone na barra do app superior. O ícone indica a saída do App e, ao clicá-lo, ele volta para a tela de autenticação, ao sair do App e abrir novamente, apresenta a tela de autenticação.

Pronto! Concluímos a implementação de uma parte do fluxo de autenticação de usuário no app Panucci - tudo isso com o Jetpack Compose!

Para saber mais: Melhorias na implementação

Dado que o objetivo do artigo era focar na implementação do fluxo de login, não foram consideradas diversas técnicas e boas práticas no código de navegação.

Caso você tenha interesse em aplicar essa melhorias, sugerimos o uso do Type Safety com a DSL do Kotlin em conjunto com o gerenciamento de estados com ViewModel. Você pode encontrar esses conteúdos na documentação do Navigation, se preferir, também você pode conferir o curso de Type Safety no Navigation da Alura que foca nesses detalhes.

Desafio: Funcionalidades para o fluxo de login

Além do que abordamos neste artigo, você também pode adicionar novas funcionalidades ao aplicativo, por exemplo, criar uma tela de cadastro de usuário que permite salvar usuário com senha e salvar as informações em um banco de dados local. Então, pode integrar o banco de dados com a tela de autenticação para entrar apenas se o usuário e senha corresponderem.

Interessante, não é? Será que você topa esse desafio? Se conseguir realizar, compartilha nas redes sociais e me marca 😉:

Conclusão

Neste artigo, abordamos como implementar um fluxo de login no Navigation com o Jetpack Compose. Nesta implementação aprendemos:

  • As estratégias do fluxo de autenticação;
  • Como salvar o estado de usuário logado com o DataStore;
  • Configurar a navegação condicional para exibir o destino inicial ou a tela de autenticação;
  • Limpar adequadamente a back stack dependendo da navegação realizada;
  • Implementar o comportamento para sair do App.

Se você se interessa pelo tema do Jetpack Compose e quer mergulhar e aprender ainda mais sobre essa tecnologia, recomendamos que conheça a Formação Jetpack Compose: criando telas e gerenciando estados.

Bons estudos e até a próxima!

Alex Felipe
Alex Felipe

Alex é instrutor e desenvolvedor e possui experiência em Java, Kotlin, Android. Atualmente cria conteúdo no canal https://www.youtube.com/@AlexFelipeDev.

Veja outros artigos sobre Mobile