Alura > Cursos de Mobile > Cursos de React Native > Conteúdos de React Native > Primeiras aulas do curso React Native: armazenando imagens com Cloud Storage

React Native: armazenando imagens com Cloud Storage

Instalando o Cloud Storage - Apresentação

Olá! Sou o instrutor André Cunha, e dou as boas vindas a mais um curso da formação de Firebase com React Native!

Em nossas aulas, aprenderemos a utilizar os serviços do Cloud Storage, que é uma forma de armazenar e exibir conteúdos facilmente nos aplicativos.

Este curso é para quem quer integrar nos apps uma forma de gerenciar upload, download e exclusão de arquivos em nuvem.

Para isso, é necessário que tenhamos feito o curso de React Native: Autenticação com Firebase neste link, em que aprendemos a utilizar a ferramenta e fizemos as configurações iniciais do projeto.

Também é interessante termos feito o curso de React Native: Armazenando dados no Firestore neste link, em que criamos um banco de dados online utilizando o serviço do Firebase.

Neste curso, trabalharemos no aplicativo SpaceApp, voltado para astronomia em que será possível fazer diversas postagens sobre fenômenos do universo.

Página inicial da aplicação "SpaceApp" exibida em uma tela de celular. O fundo é azul e, na barra superior da tela, está o horário, os ícones de aplicativos, de wifi, sinal e de bateria. No canto superior direito da página, está o logotipo de "SpaceApp" escrito em branco ao lado de um desenho simplificado de um planeta com anéis, em roxo. No canto superior esquerdo, está o ícone de sino que representa as notificações. Ocupando toda a parte central, estão dois cartões um acima do outro. No primeiro, há uma fotografia da lua na parte superior e informações na parte inferior escritas em branco sobre um fundo azul escuro: Lua, Telescópio na linha abaixo e "Satélite natural da terra" na seguinte. No segundo cartão, há a fotografia de uma galáxia e dados abaixo: "Galáxia" e "Hubble". No canto inferior direito da tela, está um ícone redondo de adição.

Teremos uma tela inicial exibindo cards com informações das postagens, onde poderemos clicar para exibirmos mais detalhes. Pressionando o cursor sobre o cartão, conseguiremos fazer toda sua edição, seja de título, fonte e descrição, ou até mesmo remoção ou alteração de uma imagem.

Mesma aplicação anterior aberta na página de "Editar Post". No canto superior esquerdo da tela, está o ícone de uma seta preta apontando para a esquerda indicando o retorno à página anterior, ao lado de "Post". Na área principal da página de edição, está o título à esquerda e um ícone de lixeira à direita. Abaixo, estão três campos de texto um em cima do outro ocupando a metade superior da tela. No topo, a legenda é "Título" e o conteúdo é "Lua", no campo do meio, a legenda é "Fonte" e o conteúdo é "Telescopio", e por fim o último campo é "Descrição" com o texto "Satélite natural da Terra". Na metade inferior da tela, está a fotografia da lua. Na base, há o botão azul de "Salvar" escrito em branco.

Poderemos criar novas postagens escolhendo uma imagem diretamente da galeria de nosso dispositivo.

Página de adição de nova postagem da mesma aplicação "SpaceApp" com os mesmos elementos da de edição anterior. Porém, seu título é "Novo Post", não há o ícone de lixeira, conteúdo dos campos de texto estão vazios e não há imagem na metade inferior da tela, apenas um ícone representando uma fotografia.

Também conseguiremos editar nossa fotografia, como cortar, rotacionar e outras ações.

Aplicaremos boas práticas de programação para fazermos todo o gerenciamento desses arquivos salvos em nuvem, evitarmos desperdício e economizarmos recursos com a boa gestão de dados.

Vamos lá!

Instalando o Cloud Storage - Introdução ao Firebase Cloud Storage

Agora que já sabemos mais sobre o que abordaremos no curso, vamos entender mais sobre o Cloud Storage.

É um dos serviços do Firebase oferecido pelo Google que foca em armazenamento de arquivos, como fotos, vídeos, músicas, PDFs e qualquer outro no geral.

Anteriormente, aprendemos sobre o Firestore, que é um banco de dados que armazena strings, numbers, arrays e assim por diante. O Cloud Storage também é, porém focado em arquivos.

Na documentação do Firebase neste link, perceberemos que há diversos recursos, mas os principais são as:

"Operações robustas: Os SDKs do Firebase para Cloud Storage realizam uploads e downloads independentemente da qualidade da rede. Os uploads e downloads são robustos, o que significa que eles são reiniciados de onde pararam, economizando tempo e largura de banda dos usuários".

Por exemplo, se alguém estiver baixando um vídeo muito grande, e a internet acabar no meio do processo, quando ela estiver disponível novamente, o download continuará de onde parou até finalizar.

Todo esse gerenciamento oferecido pela Google é bastante interessante, tanto para a experiência da pessoa usuária quanto para a performance da aplicação.

"Segurança forte: Os SDKs do Firebase para Cloud Storage se integram ao Firebase Authentication para fornecer autenticação simples e intuitiva para desenvolvedores. Você pode usar nosso modelo de segurança declarativo para permitir o acesso com base no nome do arquivo, tamanho, tipo de conteúdo e outros metadados."

É a segurança que o Google garante, e conseguiremos até veicular com o Authentication que vimos no curso sobre Autenticação.

Por exemplo, uma das funcionalidades possíveis de serem inseridas na aplicação seria permitirmos que apenas uma pessoa usuária logada específica possa fazer o upload de vídeos.

"Alta escalabilidade:O Cloud Storage é desenvolvido para escala de exabytes quando seu aplicativo se torna viral. Cresça sem esforço do protótipo para a produção usando a mesma infraestrutura que alimenta o Spotify e o Google Fotos."

Se iniciamos fazendo um aplicativo que apenas poucas pessoas estão utilizando e, depois escalemos bastante de um certo tempo e esse número se multiplicar para muitas mais pessoas, não perderemos performance por conta da demanda crescente.

O Google garante esse recurso, e se o Spotify e o Google Fotos o utilizam mesmo tendo milhões de usuárias e usuários simultaneamente, saberemos que nossa aplicação continuará performando bem também com o Cloud Storage.

A vantagem de usá-lo é justamente não dependermos de armazenamento de arquivos no próprio aplicativo, afinal as pessoas podem perder o celular, formatar ou até mesmo desinstalar.

Caso nossa aplicação apenas salve no armazenamento interno, quando a usuária ou usuário logar novamente, é possível que perca todo o processo.

Com o armazenamento em nuvem, é possível fazer o login em um dispositivo diferente em outra região e acessar suas informações salvas online.

Porém, este serviço possui um valor, e se formos à barra superior de opções da página da documentação do Firebase, poderemos clicar em "Mais > Preços". Acessaremos todos os produtos, e avançaremos para a parte do Cloud Storage mais adiante na página.

Cloud Storage
GB de armazenamento5GB$0,026/GB
GB de download1GB/diaUS$ 0,12/GB
Operações de upload20 mil/diaUS$ 0,05/10 mil
Operações de download50 mil/diaUS$ 0,004/10 mil
Vários buckets por projetonãosim

A coluna do meio diz respeito aos recursos disponíveis na versão gratuita, e a coluna da direita são os preços que começaremos a pagar caso consumamos toda a parte gratuita.

Então, para armazenamento em nuvem usando esse serviço, teremos 5GB grátis por projeto, e se ultrapassarmos esse valor, teremos a cobrança de US$0,026 dólares por gigabyte a mais até o momento deste curso.

Também conseguiremos fazer o download de cerca de 1GB por dia em arquivos do nosso banco de dados. Se consumirmos mais, pagaremos US$0,12 por gigabyte excedente.

Conseguiremos fazer vinte mil operações de upload e cinquenta mil de download.

Portanto, perceberemos que há vários recursos gratuitos que poderemos utilizar, mas, dependendo do nosso projeto que pode crescer bastante, pode ser mais interessante focar em um outro serviço ou começar a pagar os do Firebase.

Porém, como mencionamos em outros cursos, para um Produto Mínimo Viável (MVP) de quando desenvolvemos um projeto e queremos validar no mercado para entender sua utilidade, o Firebase com Cloud Storage é uma excelente opção.

Agora que já aprendemos o que é e para o quê serve, começaremos a integrá-lo em nosso projeto com React Native a seguir.

Instalando o Cloud Storage - Conhecendo o App

Agora que entendemos mais sobre o Cloud Storage, vamos conhecer nosso projeto base: o SpaceApp.

É um aplicativo de catálogo de eventos sobre astronomia, pois no ano de 2023 há diversos deles, como eclipses lunares, solares, viagens fora da Terra entre outros, e nossa proposta é registrá-los.

O Espaço é algo que evolui bastante nos nossos aplicativos. Pode parecer que não, mas é graças aos satélites em órbita que conseguirmos acessar o GPS em nossos dispositivos em diversas aplicações.

A ideia é que seja um catálogo de eventos, contendo informações sobre a Lua e a Via Láctea, por exemplo, além de armazenar imagens usando o Cloud Storage.

No VSCode, abriremos nosso projeto que já foi acessado no Space App no GitHub neste link, teremos a estrutura de pastas na barra lateral esquerda de "Explorador", que é bastante similar a todos os outros que fizemos nesta formação.

Temos o arquivo App.js que basicamente importa as fotos que vêm da pasta "src" de source ou "fonte". Dentro dela, o rotas.js importa duas telas, a Principal que está sendo exibida no aplicativo, e uma outra chamada Post onde registraremos uma postagem ou atualizaremos.

Também temos o <NavigationContainer> que adiciona essas duas rotas da nossa aplicação.

import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

const Tab = createNativeStackNavigator();

import Principal from './telas/Principal';
import Post from './telas/Post';

export default function Rotas() {
  return (
    <NavigationContainer>
      <Tab.Navigator>
        <Tab.Screen name="Principal" component={Principal} options={{ headerShown: false }} />
        <Tab.Screen name="Post" component={Post} options={{ headerStyle: { backgroundColor: '#417fea' }}}/>
      </Tab.Navigator>
    </NavigationContainer>
  );
}

Em nossa estrutura de pastas, temos a "assets" dentro de "src" que contém várias imagens para usarmos ao longo do projeto.

Já a pasta "componentes" contém alguns componentes que abordaremos mais adiante.

A "config" que possui o arquivo firebase.js é exatamente igual ao feito nos cursos anteriores, contendo as importações das variáveis de ambiente para configurarmos o Firebase, e o estamos inicializando junto com o Firestore.

import { initializeApp } from "firebase/app";
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { API_KEY, AUTH_DOMAIN, PROJECT_ID, STORAGE_BUCKET, MESSAGING_SENDER_ID, APP_ID } from "@env"

const firebaseConfig = {
  apiKey: API_KEY,
  authDomain: AUTH_DOMAIN,
  projectId: PROJECT_ID,
  storageBucket: STORAGE_BUCKET,
  messagingSenderId: MESSAGING_SENDER_ID,
  appId: APP_ID
};

// Inicializa o Firebase
const app = initializeApp(firebaseConfig);

// Inicializa o Firestore
const db = getFirestore(app);

const storage = getStorage(app)

export { db, storage };

Mesmo que este curso tematize o Cloud Storage para armazenar arquivos, usaremos o Firestore para armazenarmos informações do post em strings, como o nome do corpo celeste por exemplo.

Também temos a pasta "servicos" que possuem todas as funções que desenvolvemos para o Firestore anteriormente em outro curso dentro de firestore.js, as salvarPost(), pegarPost(), pegarPostsTempoReal(), atualizarPost() e deletarPost(). Então já as conhecemos e sabemos como funcionam.

import { db } from "../config/firebase";
import { collection, addDoc, getDocs, doc, updateDoc, deleteDoc, query, onSnapshot } from "firebase/firestore"

export async function salvarPost(data){
  try {
    const result = await addDoc(collection(db, 'posts'), data)
    return result.id
  } catch(error){
    console.log('Erro add post:', error)
    return 'erro'
  }
}

export async function pegarPosts(){
  try {
    const querySnapshot = await getDocs(collection(db, "posts"));
    let posts = []
    querySnapshot.forEach((doc) => {
      let post = {id: doc.id, ...doc.data()}
      posts.push(post)
    });
    return posts
  }catch(error){
    console.log(error)
    return []
  }
}

export async function pegarPostsTempoReal(setposts){
  const ref = query(collection(db, "posts"))
  onSnapshot(ref, (querySnapshot) => {
    const posts = []
    querySnapshot.forEach(( doc ) => {
      posts.push({id: doc.id, ...doc.data()})
    })
    setposts(posts)
  })
}

export async function atualizarPost(postID, data){
  try {
    const postRef = doc(db, "posts", postID);
    await updateDoc(postRef, data)
    return 'ok'
  }
  catch(error){
    console.log(error)
    return 'error'
  }
}

export async function deletarPost(postID){
  try {
    const postRef = doc(db, "posts", postID);
    await deleteDoc(postRef)
    return 'ok'
  }
  catch(error){
    console.log(error)
    return 'error'
  }
}

Em seguida, teremos a pasta "telas > Principal" com index.js dentro, em que há o <Cabecalho /> contendo um ícone de sino clicável que ainda não possui nenhuma ação e a logo do SpaceApp.

Em seguida, encontraremos uma <ScrollView>, que é uma área rolável verticalmente. Assim que tivermos mais postagens publicadas, conseguiremos scrollar para as visualizarmos.

Teremos informações de título, fonte, descrição e algumas ações que ainda entenderemos como funcionam. Por fim, há também o botao <NovoPostBotao> que nos direciona para a tela que cria a postagem.

import { View, ScrollView, Image } from "react-native";
import { useEffect, useState } from "react";
import { Cabecalho } from "../../componentes/Cabecalho";
import { CartaoInfo } from "../../componentes/CartaoInfo";
import { NovoPostBotao } from "../../componentes/NovoPostBotao";
import { pegarPostsTempoReal } from "../../servicos/firestore";
import estilos from "./estilos";
import { storage } from "../../config/firebase";
import { ref, getDownloadURL } from 'firebase/storage'

export default function Principal({ navigation }) {
    const [posts, setPosts] = useState([]);

    useEffect(() => {
        const imagemRef = ref(storage, 'img4.png')

        getDownloadURL(imagemRef).then((url) => {
            console.log(url)
        })

        pegarPostsTempoReal(setPosts);
    },[])



    return (
        <View style={estilos.container}>
            <Cabecalho />

            <Image 
                source={{ uri: 'https://firebasestorage.googleapis.com/v0/b/spaceapp-d3bc1.appspot.com/o/img4.png?alt=media&token=9b0d7b01-0d3b-4930-802d-b7ed2c8730b0' }}
                style={{ width: 200, height: 200 }}
            />

            <ScrollView style={estilos.scroll} showsVerticalScrollIndicator={false}>

                {posts?.map((item) => (
                    <CartaoInfo 
                        key={item.id} 
                        titulo={item.titulo}  
                        fonte={item.fonte} 
                        descricao={item.descricao} 
                        acao={() => navigation.navigate("Post", { item })}
                    />
                ))}
            </ScrollView>

            <NovoPostBotao acao={() => navigation.navigate("Post")} />
        </View>
    );
}

Já em "src > telas > Post", encontraremos três arquivos: entradas.js e estilos.js que possuem todo o CSS da aplicação, e o index.js.

Abrindo este último, teremos as informações do post que queremos armazenar contendo as propriedades de título, fonte e descrição apenas.

Mais adiante, teremos uma função salvar() para salvarmos uma postagem, contendo um if() para verificarmos se deveremos atualizar uma com atualizarPost() ou se salvaremos com salvarPost().

Dentro da estrutura da nossa <View> mais adiante, temos o título de "Novo Post" caso seja um novo post mesmo, ou "Editar Post" caso seja uma edição.

Abaixo, temos um <IconeClicavel> que é um componente que basicamente irá ser uma lixeira para o caso de uma postagem já criada que queremos deletar.

Em seguida, temos uma <ScrollView> que está recebendo dados da entrada. Como vimos anteriormente, estamos criando nossos inputs com base no arquivo que é um vetor de objetos e contém as informações que queremos exibir: id: do input, name: do título e label: da descrição.

export const entradas = [
  {
    id: '1',
    name: 'titulo',
    label: 'Título'
  },
  {
    id: '2',
    name: 'fonte',
    label: 'Fonte',
  },
  {
    id: '3',
    name: 'descricao',
    label: 'Descrição',
    multiline: true,
  },
]

De volta à tela do index.js de "Post", teremos alguma determinações dentro do <TextInput>, que é seu valor value pegando entrada.nome, o placeholder de label e a opção multiline, em que somente o terceiro item de entradas.js que o possui igual a true.

Basicamente, temos um campo de descrição e, quando apertarmos a tecla "Enter" ao digitarmos algo, iremos para a linha de baixo, diferente de um input que escreve em apenas uma linha.

Por fim, há o botão que salva em <TouchableOpacity>. Vamos testar e tentar criar uma postagem na tela do emulador.

import { useState } from "react";
import { View, Text, TextInput, ScrollView, TouchableOpacity } from "react-native";
import { salvarPost, atualizarPost, deletarPost } from "../../servicos/firestore";
import estilos from "./estilos";
import { entradas } from "./entradas";
import { alteraDados } from "../../utils/comum";
import { IconeClicavel } from "../../componentes/IconeClicavel";


export default function Post({ navigation, route }) {
    const [desabilitarEnvio, setDesabilitarEnvio] = useState(false);
    const { item } = route?.params || {};

    const [post, setPost] = useState({
        titulo: item?.titulo || "",
        fonte: item?.fonte || "",
        descricao: item?.descricao || ""
    });

    async function salvar() {
        setDesabilitarEnvio(true);
        if (item) {
            await atualizarPost(item.id, post);
        } else {
            await salvarPost(post);
        }

        navigation.goBack();
    }

    return (
        <View style={estilos.container}>
            <View style={estilos.containerTitulo}>
                <Text style={estilos.titulo}>{item ? "Editar post" : "Novo Post"}</Text>
                <IconeClicavel 
                    exibir={!!item} 
                    onPress={() => {deletarPost(item.id); navigation.goBack()}}
                    iconeNome="trash-2" 
                />
            </View>
            <ScrollView style={{ width: "100%" }}>
                {entradas?.map((entrada) => (
                    <View key={entrada.id}>
                        <Text style={estilos.texto}>{entrada.label}</Text>
                        <TextInput
                            value={post[entrada.name]}
                            placeholder={entrada.label}
                            multiline={entrada.multiline}
                            onChangeText={(valor) => 
                                alteraDados(
                                    entrada.name, 
                                    valor, 
                                    post, 
                                    setPost
                                )
                            }
                            style={
                                [estilos.entrada, entrada.multiline && estilos.entradaDescricao]
                            }
                        />
                    </View>
                ))}
            </ScrollView>

            <TouchableOpacity style={estilos.botao} onPress={salvar} disabled={desabilitarEnvio}>
                <Text style={estilos.textoBotao}>Salvar</Text>
            </TouchableOpacity>
        </View>
    );
}

Na página "Novo Post", digitaremos "Lua" no campo "Titulo", "Telescopio" em "Fonte" e "Satélite natural da Terra" no campo de "descrição".

Salvaremos e notamos uma postagem com um novo cartão azul escuro na tela inicial, contendo o texto que inserimos nos dois primeiros campos e, quando clicarmos sobre o card, exibiremos a descrição e se clicarmos novamente, ela se esconderá.

Se pressionarmos sobre o cartão, voltaremos à tela de "Post" mas com o título "Editar post" com todas as informações carregadas, conforme a verificação dos itens das rotas que passamos por parâmetro.

Também temos a opção do <InconeClicavel> de exibir que verifica se realmente há um item com !!item. Caso positivo, exibiremos a lixeira ao lado de "Editar Post".

Se clicarmos nesse ícone, iremos deletar o cartão. Mas se apenas editarmos, digitarmos mais coisas em algum dos campos e salvarmos, não criaremos uma nova postagem, e sim atualizaremos a que publicamos.

No arquivo index.js da tela principal, analisaremos o componente <CartaoInfo> e notaremos que basicamente é um clicável com <TouchableOpacity>.

Ao clicarmos e executarmos onPress, exibiremos a descrição com !mostrarDescricao, e se clicarmos mais longamente e executarmos onLongPress, faremos uma determinada ação com acao, que neste caso seria navegar para a tela de posts enviando as informações.

O restante é a exibição do título, fonte e descrição.

Observando os outros componentes, temos o "Cabecalho" contendo o index.js que possui <IconeClicavel> com o ícone de sino "bell" e uma imagem <Image>. O ícone clicável é um touchable importado do "react-native-vector-icons/Feather", como vemos no index.js da pasta "IconeClicavel".

import { TouchableOpacity } from "react-native";
import Icon from "react-native-vector-icons/Feather";

export function IconeClicavel({
  exibir=true,
  onPress,
  iconeNome="trash-2",
  iconeTamanho=25,
  iconeCor="#fff",
}){

  if(!exibir) return null;

  return (
    <TouchableOpacity onPress={onPress}>
      <Icon name={iconeNome} size={iconeTamanho} color={iconeCor} />
    </TouchableOpacity>
  );
}

Já nos inputs do index.js da pasta "Input", teremos um <TextInput> comum do próprio React Native.

import { TextInput, View } from "react-native";
import Icon from "react-native-vector-icons/Feather";
import estilos from "./estilos";

export function Input({ placeholder, valor, acao }) {
    return (
        <View style={estilos.container}>
            <TextInput
                style={estilos.input}
                placeholder={placeholder}
                value={valor}
                onChangeText={acao}
                placeholderTextColor="#D9D9D9"
            />
            <Icon name="search" size={20} color="#D9D9D9" style={estilos.icon} />
        </View>
    );
}

Por fim, em "NovoPostBotao", temos o arquivo index.js com NovoPostBotao() que é o botão que nos permite navegar para a tela de postagens.

import { TouchableOpacity, Text } from "react-native";
import estilos from "./estilos";

export function NovoPostBotao({ acao }) {
    return (
        <TouchableOpacity style={estilos.botao} onPress={acao}>
            <Text style={estilos.textoBotao}>+</Text>
        </TouchableOpacity>
    );
}

Agora que já entendemos melhor toda a estrutura da aplicação, iremos criá-la no Cloud Storage para integrá-lo ao projeto.

Sobre o curso React Native: armazenando imagens com Cloud Storage

O curso React Native: armazenando imagens com Cloud Storage possui 126 minutos de vídeos, em um total de 46 atividades. Gostou? Conheça nossos outros cursos de React Native em Mobile, ou leia nossos artigos de Mobile.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda React Native acessando integralmente esse e outros cursos, comece hoje!

Plus

De
R$ 1.800
12X
R$109
à vista R$1.308
  • Acesso a TODOS os cursos da Alura

    Mais de 1500 cursos completamente atualizados, com novos lançamentos todas as semanas, emProgramação, Front-end, UX & Design, Data Science, Mobile, DevOps e Inovação & Gestão.

  • Alura Challenges

    Desafios temáticos para você turbinar seu portfólio. Você aprende na prática, com exercícios e projetos que simulam o dia a dia profissional.

  • Alura Cases

    Webséries exclusivas com discussões avançadas sobre arquitetura de sistemas com profissionais de grandes corporações e startups.

  • Certificado

    Emitimos certificados para atestar que você finalizou nossos cursos e formações.

Matricule-se

Pro

De
R$ 2.400
12X
R$149
à vista R$1.788
  • Acesso a TODOS os cursos da Alura

    Mais de 1500 cursos completamente atualizados, com novos lançamentos todas as semanas, emProgramação, Front-end, UX & Design, Data Science, Mobile, DevOps e Inovação & Gestão.

  • Alura Challenges

    Desafios temáticos para você turbinar seu portfólio. Você aprende na prática, com exercícios e projetos que simulam o dia a dia profissional.

  • Alura Cases

    Webséries exclusivas com discussões avançadas sobre arquitetura de sistemas com profissionais de grandes corporações e startups.

  • Certificado

    Emitimos certificados para atestar que você finalizou nossos cursos e formações.

  • Luri powered by ChatGPT

    Luri é nossa inteligência artificial que tira dúvidas, dá exemplos práticos e ajuda a mergulhar ainda mais durante as aulas. Você pode conversar com Luri até 100 mensagens por semana.

  • Alura Língua (incluindo curso Inglês para Devs)

    Estude a língua inglesa com um curso 100% focado em tecnologia e expanda seus horizontes profissionais.

Matricule-se
Conheça os Planos para Empresas

Acesso completo
durante 1 ano

Estude 24h/dia
onde e quando quiser

Novos cursos
todas as semanas