1. Antes de começar
Os Progressive Web Apps (PWAs) são um tipo de software de aplicativo veiculado pela Web e criado com tecnologias comuns da Web, incluindo HTML, CSS e JavaScript. Eles foram criados para funcionar em qualquer plataforma que use um navegador compatível com padrões.
Neste codelab, você vai começar com um PWA básico e depois conhecer novos recursos do navegador que vão dar superpoderes ao seu PWA 🦸.
Muitos desses novos recursos do navegador estão em andamento e ainda estão sendo padronizados. Por isso, às vezes, você precisa definir flags do navegador para usá-los.
Pré-requisitos
Para este codelab, você precisa conhecer JavaScript moderno, especificamente promessas e async/await. Como nem todas as etapas do codelab são compatíveis com todas as plataformas, é útil ter outros dispositivos disponíveis para teste, por exemplo, um smartphone Android ou um laptop usando um sistema operacional diferente do dispositivo em que você edita o código. Como alternativa aos dispositivos reais, você pode usar simuladores, como o simulador do Android, ou serviços on-line, como o BrowserStack, que permitem testar no seu dispositivo atual. Caso contrário, você pode pular qualquer etapa, já que elas não dependem umas das outras.
O que você vai criar
Você vai criar um app Web de cartão de felicitações e aprender como os recursos novos e futuros do navegador podem melhorar seu app para oferecer uma experiência avançada em determinados navegadores, mas continuar útil em todos os navegadores modernos.
Você vai aprender a adicionar recursos de suporte, como acesso ao sistema de arquivos, acesso à área de transferência do sistema, recuperação de contatos, sincronização periódica em segundo plano, bloqueio de ativação da tela, recursos de compartilhamento e muito mais.
Depois de concluir o codelab, você vai entender bem como melhorar progressivamente seus apps da Web com novos recursos do navegador, sem sobrecarregar com downloads o subconjunto de usuários que usam navegadores incompatíveis e, mais importante, sem excluir essas pessoas do seu app.
O que é necessário
No momento, os navegadores totalmente compatíveis são:
Recomendamos usar o canal de desenvolvimento específico.
2. Projeto Fugu
Os Progressive Web Apps (PWAs) são criados e aprimorados com APIs modernas para oferecer recursos, confiabilidade e facilidade de instalação aprimorados, alcançando qualquer pessoa na Web, em qualquer lugar do mundo, usando qualquer tipo de dispositivo.
Algumas dessas APIs são muito eficientes e, se forem processadas incorretamente, as coisas podem dar errado. Assim como o peixe fugu 🐡: quando você corta certo, é uma iguaria, mas quando corta errado, pode ser letal. Não se preocupe, nada pode quebrar neste codelab.
Por isso, o codinome interno do projeto Web Capabilities (em que as empresas envolvidas estão desenvolvendo essas novas APIs) é Project Fugu.
Os recursos da Web já permitem que grandes e pequenas empresas criem soluções baseadas apenas em navegadores, muitas vezes permitindo uma implantação mais rápida com custos de desenvolvimento menores em comparação com a abordagem específica da plataforma.
3. Primeiros passos
Faça o download de um dos navegadores e defina a seguinte flag de tempo de execução 🚩 navegando até about://flags
, que funciona no Chrome e no Edge:
#enable-experimental-web-platform-features
Depois de ativar, reinicie o navegador.
Você vai usar a plataforma Glitch, porque ela permite hospedar seu PWA e tem um editor decente. O Glitch também aceita importação e exportação para o GitHub, então não há dependência de fornecedor. Acesse fugu-paint.glitch.me para testar o aplicativo. É um app de desenho básico 🎨 que você vai melhorar durante o codelab.
Depois de testar o aplicativo, faça um remix dele para criar sua própria cópia editável. O URL do seu remix será parecido com glitch.com/edit/#!/bouncy-candytuft ("bouncy-candytuft" será outra coisa para você). Esse remix está acessível diretamente no mundo todo. Faça login na sua conta ou crie uma nova no Glitch para salvar seu trabalho. Para ver o app, clique no botão "🕶 Mostrar". O URL do app hospedado será algo como bouncy-candytuft.glitch.me. Observe o .me
em vez de .com
como domínio de nível superior.
Agora você pode editar e melhorar seu app. Sempre que você fizer mudanças, o app será recarregado e as mudanças ficarão imediatamente visíveis.
O ideal é que as tarefas a seguir sejam concluídas em ordem, mas, conforme observado acima, você sempre pode pular uma etapa se não tiver acesso a um dispositivo compatível. Cada tarefa é marcada com 🐟, um peixe de água doce inofensivo, ou 🐡, um peixe baiacu "manuseie com cuidado", alertando sobre o nível experimental de um recurso.
Verifique o console no DevTools para saber se uma API é compatível com o dispositivo atual. Também usamos o Glitch para que você possa verificar o mesmo app em diferentes dispositivos, por exemplo, no smartphone e no computador desktop.
4. 🐟 Adicionar suporte à API Web Share
Criar os desenhos mais incríveis não tem graça se não houver ninguém para apreciar. Adicione um recurso que permita aos usuários compartilhar desenhos com o mundo na forma de cartões de felicitações.
A API Web Share permite compartilhar arquivos. Como você deve se lembrar, um File
é apenas um tipo específico de Blob
. Portanto, no arquivo chamado share.mjs
, importe o botão de compartilhamento e uma função de conveniência toBlob()
que converte o conteúdo de uma tela em um blob e adicione a funcionalidade de compartilhamento de acordo com o código abaixo.
Se você implementou isso, mas o botão não aparece, é porque seu navegador não implementa a API Web Share.
import { shareButton, toBlob } from './script.mjs';
const share = async (title, text, blob) => {
const data = {
files: [
new File([blob], 'fugu-greeting.png', {
type: blob.type,
}),
],
title: title,
text: text,
};
try {
if (!navigator.canShare(data)) {
throw new Error("Can't share data.", data);
}
await navigator.share(data);
} catch (err) {
console.error(err.name, err.message);
}
};
shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});
5. 🐟 Adicionar suporte à API Web Share Target
Agora seus usuários podem compartilhar cartões de felicitações criados com o app, mas você também pode permitir que eles compartilhem imagens com o app e as transformem em cartões. Para isso, use a API Web Share Target.
No manifesto do aplicativo da Web, você precisa informar ao app quais tipos de arquivos podem ser aceitos e qual URL o navegador deve chamar quando um ou vários arquivos são compartilhados. O trecho abaixo do arquivo manifest.webmanifest
mostra isso.
{
"share_target": {
"action": "./share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image",
"accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
}
]
}
}
}
Em seguida, o service worker processa os arquivos recebidos. O URL ./share-target/
não existe. O app apenas age nele no manipulador fetch
e redireciona a solicitação para o URL raiz adicionando um parâmetro de consulta ?share-target
:
self.addEventListener('fetch', (fetchEvent) => {
/* 🐡 Start Web Share Target */
if (
fetchEvent.request.url.endsWith('/share-target/') &&
fetchEvent.request.method === 'POST'
) {
return fetchEvent.respondWith(
(async () => {
const formData = await fetchEvent.request.formData();
const image = formData.get('image');
const keys = await caches.keys();
const mediaCache = await caches.open(
keys.filter((key) => key.startsWith('media'))[0],
);
await mediaCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})(),
);
}
/* 🐡 End Web Share Target */
/* ... */
});
Quando o app é carregado, ele verifica se esse parâmetro de consulta está definido e, em caso afirmativo, desenha a imagem compartilhada na tela e a exclui do cache. Tudo isso acontece em script.mjs
:
const restoreImageFromShare = async () => {
const mediaCache = await getMediaCache();
const image = await mediaCache.match('shared-image');
if (image) {
const blob = await image.blob();
await drawBlob(blob);
await mediaCache.delete('shared-image');
}
};
Essa função é usada quando o app é inicializado.
if (location.search.includes('share-target')) {
restoreImageFromShare();
} else {
drawDefaultImage();
}
6. 🐟 Adicionar suporte para importar imagens
Desenhar tudo do zero é difícil. Adicione um recurso que permita aos usuários fazer upload de uma imagem local do dispositivo para o app.
Primeiro, leia sobre a função drawImage()
do canvas. Em seguida, conheça o elemento <input
.
type=file>
Com esse conhecimento, edite o arquivo chamado import_image_legacy.mjs
e adicione o seguinte snippet. Na parte de cima do arquivo, importe o botão de importação e uma função de conveniência drawBlob()
que permite desenhar um blob na tela.
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png, image/jpeg, image/*';
input.addEventListener('change', () => {
const file = input.files[0];
input.remove();
return resolve(file);
});
input.click();
});
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
7. 🐟 Adicionar suporte para exportação de imagens
Como o usuário vai salvar um arquivo criado no app no dispositivo? Tradicionalmente, isso é feito com um elemento <a
.
download>
No arquivo export_image_legacy.mjs
, adicione o conteúdo da seguinte forma: Importe o botão de exportação e uma função de conveniência toBlob()
que converte o conteúdo da tela em um blob.
import { exportButton, toBlob } from './script.mjs';
export const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
setTimeout(() => a.click(), 0);
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
exportImage(await toBlob());
});
8. 🐟 Adicionar suporte à API File System Access
Compartilhar é importante, mas os usuários provavelmente vão querer salvar o melhor trabalho nos próprios dispositivos. Adicione um recurso que permita aos usuários salvar (e reabrir) os desenhos.
Antes, você usava uma abordagem legada <input type=file>
para importar arquivos e uma abordagem legada <a download>
para exportar arquivos. Agora, você vai usar a API File System Access para melhorar a experiência.
Essa API permite abrir e salvar arquivos do sistema de arquivos do sistema operacional. Edite os dois arquivos, import_image.mjs
e export_image.mjs
, respectivamente, adicionando o conteúdo abaixo. Para que esses arquivos sejam carregados, remova os emojis de 🐡 de script.mjs
.
Substitua esta linha:
// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
/* ... */
}
...com esta linha:
if ('showOpenFilePicker' in window) {
/* ... */
}
Em import_image.mjs
:
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
try {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: 'Image files',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
},
},
],
});
return await handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
Em export_image.mjs
:
import { exportButton, toBlob } from './script.mjs';
const exportImage = async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fugu-greetings.png',
types: [
{
description: 'Image file',
accept: {
'image/png': ['.png'],
},
},
],
});
const blob = await toBlob();
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
await exportImage();
});
9. 🐟 Adicionar suporte à API Contact Picker
Seus usuários podem querer adicionar uma mensagem ao cartão de felicitações e se dirigir a alguém pessoalmente. Adicione um recurso que permita aos usuários escolher um ou vários contatos locais e adicionar os nomes deles à mensagem de compartilhamento.
Em um dispositivo Android ou iOS, a API Contact Picker permite escolher contatos no app gerenciador de contatos do dispositivo e retorná-los ao aplicativo. Edite o arquivo contacts.mjs
e adicione o código abaixo.
import { contactsButton, ctx, canvas } from './script.mjs';
const getContacts = async () => {
const properties = ['name'];
const options = { multiple: true };
try {
return await navigator.contacts.select(properties, options);
} catch (err) {
console.error(err.name, err.message);
}
};
contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
const contacts = await getContacts();
if (contacts) {
ctx.font = '1em Comic Sans MS';
contacts.forEach((contact, index) => {
ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
});
}
});
10. 🐟 Adicionar suporte à API Async Clipboard
Os usuários podem querer colar uma imagem de outro app no seu ou copiar um desenho do seu app para outro. Adicione um recurso que permita copiar e colar imagens dentro e fora do seu app. A API Async Clipboard é compatível com imagens PNG. Agora é possível ler e gravar dados de imagem na área de transferência.
Encontre o arquivo clipboard.mjs
e adicione o seguinte:
import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';
const copy = async (blob) => {
try {
await navigator.clipboard.write([
/* global ClipboardItem */
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
const paste = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
try {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
return blob;
}
} catch (err) {
console.error(err.name, err.message);
}
}
} catch (err) {
console.error(err.name, err.message);
}
};
copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
await copy(await toBlob());
});
pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
const image = new Image();
image.addEventListener('load', () => {
drawImage(image);
});
image.src = URL.createObjectURL(await paste());
});
11. 🐟 Adicionar suporte à API Badging
Quando os usuários instalarem seu app, um ícone vai aparecer na tela inicial. Você pode usar esse ícone para transmitir informações divertidas, como o número de pinceladas que um determinado desenho levou.
Adicione um recurso que conte o número de vezes que o usuário faz um novo traço de pincel. A API Badging permite definir um selo numérico no ícone do app. Você pode atualizar o ícone sempre que um evento pointerdown
acontecer (ou seja, quando um traço de pincel ocorrer) e redefinir o ícone quando a tela for limpa.
Coloque o código abaixo no arquivo badge.mjs
:
import { canvas, clearButton } from './script.mjs';
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
12. 🐟 Adicionar suporte à API Screen Wake Lock
Às vezes, os usuários precisam de alguns instantes para olhar um desenho e ter inspiração. Adicione um recurso que mantenha a tela ativada e impeça o protetor de tela de iniciar. A API Screen Wake Lock impede que a tela do usuário entre em modo de espera. O bloqueio de despertar é liberado automaticamente quando ocorre um evento de mudança de visibilidade, conforme definido pela Visibilidade da página. Portanto, o bloqueio de ativação precisa ser adquirido novamente quando a página volta a aparecer.
Encontre o arquivo wake_lock.mjs
e adicione o conteúdo abaixo. Para testar se isso funciona, configure o protetor de tela para aparecer após um minuto.
import { wakeLockInput, wakeLockLabel } from './script.mjs';
let wakeLock = null;
const requestWakeLock = async () => {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
} catch (err) {
console.error(err.name, err.message);
}
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
if (wakeLockInput.checked) {
await requestWakeLock();
} else {
wakeLock.release();
}
});
13. 🐟 Adicionar suporte à API Periodic Background Sync
Começar com uma tela em branco pode ser entediante. Você pode usar a API Periodic Background Sync para inicializar a tela dos usuários com uma nova imagem a cada dia, por exemplo, a foto diária de fugu do Unsplash.
Isso requer dois arquivos: um arquivo periodic_background_sync.mjs
que registra a sincronização periódica em segundo plano e outro arquivo image_of_the_day.mjs
que lida com o download da imagem do dia.
Em periodic_background_sync.mjs
:
import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';
const getPermission = async () => {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
return status.state === 'granted';
};
const registerPeriodicBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready;
try {
registration.periodicSync.register('image-of-the-day-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (err) {
console.error(err.name, err.message);
}
};
navigator.serviceWorker.addEventListener('message', async (event) => {
const fakeURL = event.data.image;
const mediaCache = await getMediaCache();
const response = await mediaCache.match(fakeURL);
drawBlob(await response.blob());
});
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
if (await getPermission()) {
await registerPeriodicBackgroundSync();
}
const mediaCache = await getMediaCache();
let blob = await mediaCache.match('./assets/background.jpg');
if (!blob) {
blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
}
drawBlob(await blob.blob());
});
Em image_of_the_day.mjs
:
const getImageOfTheDay = async () => {
try {
const fishes = ['blowfish', 'pufferfish', 'fugu'];
const fish = fishes[Math.floor(fishes.length * Math.random())];
const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
if (!response.ok) {
throw new Error('Response was', response.status, response.statusText);
}
return await response.blob();
} catch (err) {
console.error(err.name, err.message);
}
};
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
try {
const blob = await getImageOfTheDay();
const mediaCache = await getMediaCache();
const fakeURL = './assets/background.jpg';
await mediaCache.put(fakeURL, new Response(blob));
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: fakeURL,
});
});
} catch (err) {
console.error(err.name, err.message);
}
})(),
);
}
});
14. 🐟 Adicionar suporte à API Shape Detection
Às vezes, os desenhos dos usuários ou as imagens de plano de fundo usadas podem conter informações úteis, como códigos de barras. A API Shape Detection, e especificamente a API Barcode Detection, permite extrair essas informações. Adicione um recurso que tente detectar códigos de barras nos desenhos dos usuários. Localize o arquivo barcode.mjs
e adicione o conteúdo abaixo. Para testar esse recurso, basta carregar ou colar uma imagem com um código de barras na tela. Você pode copiar um exemplo de código de barras de uma pesquisa de imagens de QR codes.
/* global BarcodeDetector */
import {
scanButton,
clearButton,
canvas,
ctx,
CANVAS_BACKGROUND,
CANVAS_COLOR,
floor,
} from './script.mjs';
const barcodeDetector = new BarcodeDetector();
const detectBarcodes = async (canvas) => {
return await barcodeDetector.detect(canvas);
};
scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
const barcodes = await detectBarcodes(canvas);
if (barcodes.length) {
barcodes.forEach((barcode) => {
const rawValue = barcode.rawValue;
if (seenBarcodes.includes(rawValue)) {
return;
}
seenBarcodes.push(rawValue);
ctx.font = '1em Comic Sans MS';
ctx.textAlign = 'center';
ctx.fillStyle = CANVAS_BACKGROUND;
const boundingBox = barcode.boundingBox;
const left = boundingBox.left;
const top = boundingBox.top;
const height = boundingBox.height;
const oneThirdHeight = floor(height / 3);
const width = boundingBox.width;
ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
ctx.fillStyle = CANVAS_COLOR;
ctx.fillText(
rawValue,
left + floor(width / 2),
top + floor(height / 2),
width,
);
});
}
});
15. 🐡 Adicionar suporte à API Idle Detection
Se você imaginar que seu app está sendo executado em uma configuração semelhante a um quiosque, um recurso útil seria redefinir a tela após um determinado período de inatividade. A API Idle Detection permite detectar quando um usuário não interage mais com o dispositivo.
Encontre o arquivo idle_detection.mjs
e cole o conteúdo abaixo.
import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';
let controller;
ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';
ephemeralInput.addEventListener('change', async () => {
if (ephemeralInput.checked) {
const state = await IdleDetector.requestPermission();
if (state !== 'granted') {
ephemeralInput.checked = false;
return alert('Idle detection permission must be granted!');
}
try {
controller = new AbortController();
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', (e) => {
const { userState, screenState } = e.target;
console.log(`idle change: ${userState}, ${screenState}`);
if (userState === 'idle') {
clearCanvas();
}
});
idleDetector.start({
threshold: 60000,
signal: controller.signal,
});
} catch (err) {
console.error(err.name, err.message);
}
} else {
console.log('Idle detection stopped.');
controller.abort();
}
});
16. 🐡 Adicionar suporte à API File Handling
E se os usuários pudessem clicar duas vezes em um arquivo de imagem e o app aparecesse? A API File Handling permite fazer exatamente isso.
Você precisa registrar o PWA como um gerenciador de arquivos para imagens. Isso acontece no manifesto do aplicativo da Web. O trecho abaixo do arquivo manifest.webmanifest
mostra isso. Isso já faz parte do manifesto, não é necessário adicionar por conta própria.
{
"file_handlers": [
{
"action": "./",
"accept": {
"image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
}
}
]
}
Para processar os arquivos abertos, adicione o código abaixo ao arquivo file-handling.mjs
:
import { drawBlob } from './script.mjs';
const handleLaunchFiles = () => {
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) {
return;
}
launchParams.files.forEach(async (handle) => {
const file = await handle.getFile();
drawBlob(file);
});
});
};
handleLaunchFiles();
17. Parabéns
🎉 Oba, você conseguiu!
Há tantas APIs de navegador interessantes sendo desenvolvidas no contexto do Projeto Fugu 🐡 que este codelab mal consegue abordar o assunto.
Para saber mais, acompanhe nossas publicações no site web.dev.
Mas isso não é tudo. Para atualizações ainda não divulgadas, acesse nosso rastreador de API Fugu com links para todas as propostas que foram enviadas, estão em teste de origem ou teste de desenvolvimento, todas as propostas em que o trabalho começou e tudo o que está sendo considerado, mas ainda não começou.
Este codelab foi escrito por Thomas Steiner (@tomayac). Será um prazer responder às suas perguntas e ler seu feedback. Agradecimentos especiais a Hemanth H.M (@GNUmanth), Christian Liebel (@christianliebel), Sven May (@Svenmay), Lars Knudsen (@larsgk) e Jackie Han (@hanguokai), que ajudaram a criar este codelab.