Objeto: criação de um pequeno app com gerência de estado, comunicação entre componentes gráficos e acesso a api externa para carregar dados.
Principais termos técnicos abordados: programação assíncrona, async, await, Future, api, end-point, números mágicos
Requisitos para as instruções funcionarem: navegador ou ide (VS Code, Android Studio etc.)
Requisitos para compreensão das instruções: noções básicas de programação e ter realizado receita anterior, com exercícios
Como ler essa receita: instruções, dentro dos passos da receita e que requerem ação sua no computador, estarão escritas em cor azul. Comentários sobre os passos estarão em fonte normal, cor preta. Comandos, código-fonte, termos técnicos ou configuração explícita, estarão com fonte diferenciada.
Observação: eventuais instruções de terminal serão comandos Linux. Adapte caso esteja usando outro sistema operacional
"Um homem, sozinho, não é nada. Nem corno" (Pedin)
Essa é uma receita complementar à receita anterior. A ideia é, inicialmente, fazer a mesma coisa de um jeito diferente.
Em vez de usarmos async/await, vamos usar os objetos Future em conjunto com funções de callback.
O efeito será rigorosamente o mesmo.
Para a receita não ficar pequena demais, vamos nos aproveitar desse exemplo para tratar de um tema importante na programação OO: information hiding.
E, para finalizar, acrescentaremos a funcionalidade de exibir uma mensagem "Carregando" enquanto os dados não chegam da internet.
Cole o código inicial.
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class DataService{
final ValueNotifier<List> tableStateNotifier = new ValueNotifier([]);
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
funcoes[index]();
}
void carregarCafes(){
return;
}
void carregarNacoes(){
return;
}
Future<void> carregarCervejas() async{
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '5'});
var jsonString = await http.read(beersUri);
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = beersJson;
}
}
final dataService = DataService();
void main() {
MyApp app = MyApp();
runApp(app);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
return DataTableWidget(
jsonObjects:value,
propertyNames: ["name","style","ibu"],
columnNames: ["Nome", "Estilo", "IBU"]
);
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
class NewNavBar extends HookWidget {
final _itemSelectedCallback;
NewNavBar({itemSelectedCallback}):
_itemSelectedCallback = itemSelectedCallback ?? (int){}
@override
Widget build(BuildContext context) {
var state = useState(1);
return BottomNavigationBar(
onTap: (index){
state.value = index;
_itemSelectedCallback(index);
},
currentIndex: state.value,
items: const [
BottomNavigationBarItem(
label: "Cafés",
icon: Icon(Icons.coffee_outlined),
),
BottomNavigationBarItem(
label: "Cervejas", icon: Icon(Icons.local_drink_outlined)),
BottomNavigationBarItem(
label: "Nações", icon: Icon(Icons.flag_outlined))
]);
}
}
class DataTableWidget extends StatelessWidget {
final List jsonObjects;
final List<String> columnNames;
final List<String> propertyNames;
DataTableWidget( {this.jsonObjects = const [], this.columnNames = const ["Nome","Estilo","IBU"], this.propertyNames= const ["name", "style", "ibu"]});
@override
Widget build(BuildContext context) {
return DataTable(
columns: columnNames.map(
(name) => DataColumn(
label: Expanded(
child: Text(name, style: TextStyle(fontStyle: FontStyle.italic))
)
)
).toList()
,
rows: jsonObjects.map(
(obj) => DataRow(
cells: propertyNames.map(
(propName) => DataCell(Text(obj[propName]))
).toList()
)
).toList());
}
}
Isso é algo parecido com o que teríamos completando a receita anterior sem fazer os exercícios. Destaquei, em negrito, as partes que mudaremos nos passos seguintes.
Modifique a função carregarCervejas.
void carregarCervejas(){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '5'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = beersJson;
});
}
Salve, execute e volte aqui para as explicações.
Vamos por partes.
Primeiro nossa função carregarCervejas não retorna um Future e tampouco é assíncrona.
void carregarCervejas() (...)
A única função assíncrona que tem na história, agora é a http.read, mas nós não "damos await" nela.
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = beersJson;
});
Acontece que o http.read(beersUri) retorna um objeto Future<String>. Se o http.read fosse uma função síncrona, "normal", retornaria um String, que é a resposta da requisição. Como é uma função assíncrona, retorna um Future<String>, que é um objeto que, em algum ponto futuro, terá em si a resposta na forma de String. Os objetos Future têm um método then, que recebe uma função de callback como parâmetro. Essa função de callback será chamada pelo objeto Future quando a resposta chegar. Como a resposta é um String, a função recebe um String como parâmetro. Estamos passando uma função inline (ou lambda), que é (jsonString){ (...) }. Dentro dessa função escrevemos o código de deve ser executado quando a resposta chegar. Como a função de callback recebe o String da resposta como parâmetro, pode fazer o que bem entender.
E estamos fazendo a mesma coisa que fazíamos na receita anterior: convertendo a resposta em objetos json com a linha var beersJson = jsonDecode(jsonString) e alterando o estado do app com a linha tableStateNotifier.value = beersJson .
Resumindo, estamos fazendo rigorosamente a mesma coisa com um estilo de codificação diferente.
- E qual é a opção melhor, professor?
Eu, pessoalmente, prefiro usar a dobradinha await/async, acho que fica mais fácil de ler e entender o código.
Há quem prefira a dobradinha do then com a função de callback.
No final das contas, a gente precisa aprender as duas abordagens.
Note, por fim, que a gente pode fugir de declarar funções como assíncronas (usando async) mas a gente não tem como fugir da programação assíncrona. Esse tipo de situação requer programação assíncrona porque seu app não pode ficar esperando ociosamente a resposta de alguma requisição. O async/await é uma forma de lidar com a programação assíncrona. O then/callback também!
- Tô ligado. Algo mais?
Na verdade, sim.
A gente não está considerando, em lugar nenhum do nosso código, duas coisas importantes: a possível longa espera a uma eventual requisição e algum possível erro na requisição.
Uma solicitação a uma API qualquer depende, essencialmente, da internet e da máquina que processa a requisição, duas coisas que seu código não controla. Então é bom se precaver e empurrar algum componente gráfico indicando que as informações estão sendo carregadas. Também precisamos tratar eventuais erros na requisição. A máquina que processa pode estar fora do ar, por exemplo, ou pode rejeitar nossa requisição por estar sobrecarregada.
Uma solução interessante para resolver esses probleminhas seria incluir, no estado da tabela, não apenas uma lista de objetos json, mas também um status indicando se nada está acontecendo, se há uma requisição em curso, se os dados foram recebidos ou se houve um erro. A gente pode ser esperto e acrescentar também ao estado da tabela as propriedades que devem ser exibidas e os nomes das colunas. Se você fez os exercícios direitinho já deve ter feito isso há tempos, mas eu vou refazer no corpo da receita só pra ter certeza.
Primeiro, acrescente um status ao estado da tabela.
enum TableStatus{idle,loading,ready,error}
class DataService{
final ValueNotifier<Map<String,dynamic>> tableStateNotifier
= ValueNotifier({
'status':TableStatus.idle,
'dataObjects':[]
});
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
funcoes[index]();
}
void carregarCafes(){
return;
}
void carregarNacoes(){
return;
}
void carregarCervejas(){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '5'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = beersJson;
});
}
}
- Tá rodando não, Professor.
Eu sei. Mas vamos entender o que fizemos pra entender porque tá dando erro.
Primeiro a gente criou um tipo enumerado...
enum TableStatus{idle,loading,ready,error}
O nosso status vai ser uma variável deste tipo, que tem 4 valores possíveis, autodescritivos. Apesar do nome "tipo enumerado", a gente usa esse enum justamente pra fugir de números. Eu não quero por um inteiro lá no meu status pra 0 significar ociosidade, 1 significar carregando e assim por diante.
- E qual seria o problema com isso, Professor?
Legibilidade. Em algum canto do seu código você teria que fazer algo como if (status == 1) para exibir uma mensagem de que os dados estão sendo carregados. Esse 1 é o chamado "número mágico", quem não escreveu esse código não fará a menor ideia de onde ele veio nem do que ele significa. Pior ainda, com o uso reiterado de números mágicos é bem possível que nem o zé mané que os escreveu se lembre do significado deles em algum momento. Parece besteira, mas a legibilidade afeta a manutenção e mesmo a produtividade da equipe, e a gente desenvolve software em equipe. Eu já devo ter escrito isso em algum canto, mas não custa lembrar: um caba sozinho não consegue nem levar chifre.
- Mas e nessa abordagem nova, como ficaria o if?
Ficaria assim: if (status == TableStatus.loading). Fácil de entender até para um programador xibata.
Ademais, na hora de criar o estado inicial e por dentro do ValueNotifier, a gente fez...
final ValueNotifier<Map<String,dynamic>> tableStateNotifier
= ValueNotifier({
'status':TableStatus.idle,
'dataObjects':[]
});
Não deve ser tão complicado de entender. Um ValueNotifier pode armazenar qualquer coisa como estado, isso deve ter ficado claro nas receitas de gerência de estado. Em "qualquer coisa" se enquadra, logicamente, Map<String,dynamic>, que é o tipo formal de objeto JSON, e isso deve ter ficado claro nas receitas de JSON.
Eis que o estado da tabela era uma lista de objetos json, e agora esse mesmo estado é um objeto json, que tem uma lista de objetos json dentro de si, além de um status.
Acontece que a parte que foi programada para tratar o estado como uma lista de json vai dar problema!
Lá na sua classe MyApp, a gente criava o widget sensível às mudanças de estado da seguinte forma:
(...)
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
return DataTableWidget(
jsonObjects:value,
propertyNames: ["name","style","ibu"],
columnNames: ["Nome", "Estilo", "IBU"]
);
}
)
(...)
Pois bem, aquele value não é mais uma lista de objetos, mas um objeto que tem uma lista dentro de si. Altere a linha em negrito para que fique assim:
jsonObjects:value['dataObjects']
Salve e rode seu app.
- Vige, Professor, desapareceu o erro de compilação mas deu erro de execução.
É que mais gente em nosso código deve estar esperando trabalhar diretamente com uma lista de json no estado da tabela e não com um objeto json que tem uma essa lista dentro de si. E, nesse ou nesses outros casos, nem o compilador nem o analisador de código foram capazes de identificar o problema, que acabou virando um erro de execução, o popular bug.
Vamos nos alongar um pouco para resolver esse erro, porque o procedimento que vamos adotar pode ser útil para resolver outros erros em tempo de execução.
Quando nos deparamos com erros de execução, a ide que que estamos utilizando para desenvolver o app deve mostrar o que é conhecido como stack trace. O stack trace nada mais é do que o rastro do erro que aconteceu. Porque um erro vai ocasionando outro que vai ocasionando outro e assim por diante. A imagem a seguir mostra o stack trace para esse erro aqui no meu código.
- É erro demais, Professor.
Não só isso. É erro demais em lugares que a gente nem programou. Mas isso nos dá a dica de como resolver a parada, e de uma forma bem simples. Siga esse procedimento:
Vá lendo o stack trace de cima pra baixo até achar algum código que você implementou. No caso do código que eu estou implementando aqui para fazer essa receita, esse procedimento me leva ao main.dart, conforme destaque da imagem.
Agora abra o código na linha indicada no stack trace. Essa linha, dentro da função carregarCervejas, é a seguinte: tableStateNotifier.value = beersJson;
Em seguida, leia a mensagem do erro tendo em mente a linha onde o erro ocorreu. Isso deve deixar claro, na imensa maioria das vezes, a solução para o problema. Vejamos: a mensagem do erro (em vermelho, na figura, diz, em ingrêis, traduzindo livremente, algo como "macho, eu tava querendo um objeto JSON e você tá me dando uma lista de qualquer coisa".
Nesse ponto, problema e solução devem estar evidentes para você. A gente tá botando no estado da tabela a lista de json que a gente recebe da API, sendo que a gente modificou as coisas para que o estado seja um objeto JSON com uma lista e um status dentro de si. A solução é fazer o que a gente se propôs a fazer.
Modifique sua função carregarCervejas.
void carregarCervejas(){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '5'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'status': TableStatus.ready,
'dataObjects': beersJson
};
});
}
Salve e execute. A mágica deve voltar a acontecer. O toque no botão Cervejas carrega cervejas na tabela.
- Sim, Professor, mas a gente continua não fazendo nada com o status. O status tá no estado, mas não tem nenhum if daqueles e nenhuma mensagem de carregamento...
Tem mesmo não, mas a gente já tem a informação correta no canto correto. Agora fica mais fácil de implementar.
Para início de conversa a gente não está botando nenhum estado com status loading em nenhum lugar. E o lugar melhor de definir um estado com status loading é antes de fazer a requisição. Lá no carregarCervejas ou antes dele, no carregar. A gente vai por no carregar para escrever essa linha só uma vez. Se a gente puser no carregarCervejas vai ter que replicar a mesma linha no carregarCafes e no carregarNacoes.
Modifique o método carregar.
void carregar(index){
final funcoes = [carregarCafes, carregarCervejas, carregarNacoes];
tableStateNotifier.value = {
'status': TableStatus.loading,
'dataObjects': []
};
funcoes[index]();
}
Bem, agora já temos, em momentos distintos, estados com status idle, loading e ready. Já dá pra brincar com essas informações.
A questão é onde a gente pode brincar com essas informações...
E deve parecer lógico brincar com essas informações no ValueListenableBuilder, simplesmente porque esse widget tem acesso ao estado da tabela e vai ser redesenhado a cada mudança de estado. É nele que a gente pode por aquele if (status == TableStatus.loading) de que a gente falou lá atrás.
Modifique sua classe MyApp, na parte em que se cria um ValueListenableBuilder...
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
switch (value['status']){
case TableStatus.idle:
return Text("Toque algum botão");
case TableStatus.loading:
return CircularProgressIndicator();
case TableStatus.ready:
return DataTableWidget(
jsonObjects:value['dataObjects'],
propertyNames: ["name","style","ibu"],
columnNames: ["Nome", "Estilo", "IBU"]
);
case TableStatus.error:
return Text("Lascou");
}
return Text("...");
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
Salve, rode, toque no botão Cervejas e se emocione.
- Massa! Mas e o if, Professor, cadê aquele if?
O switch é a mesma coisa que uma ruma de if, caraio, e é mais adequado para essa situação em que todas as condições são feitas sobre uma variável apenas. Mas você pode escrever como um encadeamento de if's, o efeito será o mesmo.
- Vamos deixar assim mesmo. E aqueles nomes de propriedades na construção do DataTableWidget? Eles continuam hardcoded.
Continuam, mas agora é mais fácil de resolver isso.
Modifique o método carregarCervejas para acrescentar ao estado a lista de propriedades que devem ser exibidas.
void carregarCervejas(){
var beersUri = Uri(
scheme: 'https',
host: 'random-data-api.com',
path: 'api/beer/random_beer',
queryParameters: {'size': '5'});
http.read(beersUri).then( (jsonString){
var beersJson = jsonDecode(jsonString);
tableStateNotifier.value = {
'status': TableStatus.ready,
'dataObjects': beersJson,
'propertyNames': ["name","style","ibu"]
};
});
}
E modifique novamente a classe MyApp.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.deepPurple),
debugShowCheckedModeBanner:false,
home: Scaffold(
appBar: AppBar(
title: const Text("Dicas"),
),
body: ValueListenableBuilder(
valueListenable: dataService.tableStateNotifier,
builder:(_, value, __){
switch (value['status']){
case TableStatus.idle:
return Text("Toque algum botão");
case TableStatus.loading:
return CircularProgressIndicator();
case TableStatus.ready:
return DataTableWidget(
jsonObjects: value['dataObjects'],
propertyNames: value['propertyNames'],
columnNames: ["Nome", "Estilo", "IBU"]
);
case TableStatus.error:
return Text("Lascou");
}
return Text("...");
}
),
bottomNavigationBar: NewNavBar(itemSelectedCallback: dataService.carregar),
));
}
}
O efeito é o mesmo, a programação é melhor.
Pesquise: dart Future error handling.
Pesquise: dart await error handling.
Agora não tem mais desculpa. Faça com que os nomes das colunas que vão constar na tabela também façam parte do estado da tabela (em vez de ficarem hardcoded, como ainda estão)
Bonitifique aquela mensagem "toque um botão". Use uma imagem de boas vindas junto com uma mensagem ou breves instruções de como usar o app, centralize os componentes etc. Seja criativo. O app é seu.
Ponha aquele indicador de carregamento ao centro da tela, não há razão para que ele fique no canto superior esquerdo.
Implemente os métodos carregarNacoes e carregarCafes. Use, em carregarNacoes, a estratégia do aync/await e use, em carregarCafes, a estratégia do then/callback
Rode seu app. Depois ponha seu dispositivo/computador em modo avião. Depois toque em algum dos botões. Alguma mensagem de erro é exibida na interface com o usuário? Resolva isso.
Vá lá no site da random-api e consulte outros itens que eles disponibilizam para consulta. Escolha um desses itens para acrescentar ao seu app e faça a coisa acontecer. Acrescente botão, método de consulta etc. até que tudo funcione direitinho. Se você implementou todos os exercícios e recomendações das receitas, deve dar bem pouco trabalho fazer isso.
(Desafio, mas nem tanto) Faça com que sua tabela se ordene ao toque em qualquer uma das colunas, alternando-se, a cada toque, entre crescente e decrescente. Se o usuário tocar em Nome, você ordena por nome, em ordem crescente. Se tocar novamente em nome, você ordena por nome, em ordem descrescente, e assim por diante.
(Desafio) Torne o seu componente DataTableWidget mais sabido e independente. Faça com que ele saiba se ordenar sozinho, por qualquer coluna, independentemente de qualquer componente externo.