Tutorial – Crie um Lançador por Voz com IA no Google Planilhas
Se você acompanhou o nosso tutorial clássico do “Lançador de Dados”, sabe como uma interface web simplifica a vida. Mas hoje, vamos levar essa ideia para o futuro. Chega de preencher campo por campo ou lutar com o teclado do celular.
Neste tutorial completo, vamos construir o Lançador IA de Elite. Você vai aprender a integrar o Google Gemini (a inteligência artificial do Google) diretamente na sua planilha. O resultado? Você apenas fala ou digita uma frase solta e a IA entende sozinha o que é a descrição, o valor e a categoria, organizando tudo na sua planilha em segundos. É o poder da IA generativa trabalhando para a sua produtividade, de forma 100% gratuita.
const API_KEY = "xxxxxxxxxxxxxxxxxxxxx";
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('Lançador IA')
.addMetaTag('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function interpretarTextoComIA(textoUsuario) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent?key=${API_KEY}`;
// Prompt aprimorado com instruções de bloqueio
const prompt = `Analise a frase do usuário: "${textoUsuario}".
1. Se a frase NÃO for um relato de gasto, despesa ou compra, retorne apenas: {"erro": "não_é_despesa"}.
2. Se for uma despesa mas NÃO houver um valor numérico mencionado, retorne apenas: {"erro": "valor_ausente"}.
3. Se for uma despesa válida, extraia para JSON. Categorias: [Mercado, Transporte, Lazer, Contas, Restaurante].
Regra: A "descricao" deve começar com letra maiúscula.
Retorne APENAS o JSON no formato: {"descricao": "string", "valor": number, "categoria": "string"}`;
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.1, responseMimeType: "application/json" }
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const result = JSON.parse(response.getContentText());
const extraido = JSON.parse(result.candidates[0].content.parts[0].text);
// Retorna o objeto (seja ele de dados ou de erro)
return extraido;
} catch (e) {
throw new Error("A IA falhou ao processar.");
}
}
function lancarDespesa(dados) {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const aba = ss.getSheets()[0];
let valorLimpo = dados.valor.toString().replace(/[R$\s.]/g, '').replace(',', '.');
const descFinal = dados.descricao.charAt(0).toUpperCase() + dados.descricao.slice(1);
aba.appendRow([new Date(), descFinal, dados.categoria, parseFloat(valorLimpo)]);
const ultimaLinha = aba.getLastRow();
aba.getRange(ultimaLinha, 1).setNumberFormat("dd/mm/yyyy");
aba.getRange(ultimaLinha, 4).setNumberFormat("R$ #,##0.00");
return { success: true, message: "✨ Lançado com sucesso!" };
} catch (e) { return { success: false, message: "Erro: " + e.message }; }
} <!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
:root {
--primary: #4f46e5;
--bg: #f1f5f9;
--header: #1e293b;
--danger: #ef4444;
--success: #10b981;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
margin: 0;
padding: 20px;
}
.app-card {
max-width: 450px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.app-header {
background: var(--header);
color: white;
padding: 20px;
text-align: center;
}
.app-header h2 {
margin: 0;
font-size: 1.1rem;
}
.content {
padding: 20px;
}
/* Spinner de carregamento */
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Barra Mágica */
.magic-container {
display: flex;
align-items: center;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 10px;
margin-bottom: 15px;
}
.magic-container textarea {
flex: 1;
border: none;
background: transparent;
font-size: 1rem;
outline: none;
resize: none;
font-family: inherit;
font-weight: 500;
height: 45px;
}
/* Microfone - Melhorado para ser Clicável */
#micBtn {
background: none;
border: none;
outline: none;
padding: 10px;
cursor: pointer;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: 0.2s;
position: relative;
/* Garante que ele respeite a camada */
z-index: 99;
/* Joga o botão para a frente de tudo */
}
#micBtn:hover {
background: #eef2ff;
}
#micBtn.active {
color: var(--danger);
animation: pulse 1s infinite;
background: #fee2e2;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.btn-magic {
width: 100%;
padding: 14px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
cursor: pointer;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}
.btn-magic:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Form */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-size: 0.7rem;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
margin-bottom: 5px;
}
.input-field {
width: 100%;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 1rem;
outline: none;
}
.btn-manual {
width: 100%;
padding: 16px;
background: var(--header);
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: white;
border-radius: 20px;
padding: 25px;
width: 100%;
max-width: 350px;
}
.modal-data {
background: #f8fafc;
border-radius: 12px;
padding: 15px;
margin-bottom: 20px;
border-left: 4px solid var(--primary);
font-size: 0.9rem;
}
.btn-confirm {
width: 100%;
padding: 14px;
background: var(--success);
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
cursor: pointer;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-sec {
flex: 1;
padding: 12px;
border-radius: 10px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
text-align: center;
}
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
padding: 14px 25px;
border-radius: 50px;
color: white;
font-weight: 700;
display: none;
z-index: 2000;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
white-space: nowrap;
}
</style>
</head>
<body>
<div class="app-card">
<div class="app-header">
<h2>Gestor de Despesas</h2>
</div>
<div class="content">
<label style="font-size: 0.7rem; font-weight: 800; color: var(--primary); text-transform: uppercase;">✨ Lançamento Inteligente</label>
<div class="magic-container">
<textarea id="textoIA" placeholder="Fale ou digite seu gasto..."></textarea>
<!-- Mudança: microfone em um botão real para garantir clique -->
<!-- Altere o onclick do botão no HTML -->
<button id="micBtn" type="button" onclick="abrirGravadorExterno()">
<span class="material-icons">mic</span>
</button>
</div>
<button class="btn-magic" id="btnIA" onclick="processarComIA()">
<span id="labelIA">Interpretar com IA</span>
</button>
<hr style="border: 0; border-top: 1px solid #eee; margin-bottom: 20px;">
<form id="despesaForm">
<div class="form-group">
<label>Descrição</label>
<input type="text" id="descricao" class="input-field" placeholder="Ex: Mercado">
</div>
<div style="display: flex; gap: 10px;">
<div class="form-group" style="flex: 1;">
<label>Valor</label>
<input type="text" id="valor" class="input-field" placeholder="R$ 0,00" oninput="mascaraMoeda(this)">
</div>
<div class="form-group" style="flex: 1;">
<label>Categoria</label>
<select id="categoria" class="input-field">
<option value="Outros">Outros</option>
<option value="Mercado">Mercado</option>
<option value="Transporte">Transporte</option>
<option value="Lazer">Lazer</option>
<option value="Contas">Contas</option>
<option value="Contas">Restaurante</option>
</select>
</div>
</div>
<button type="button" id="btnManual" class="btn-manual" onclick="validarELancarManual()">
<span id="labelManual">LANÇAR AGORA</span>
</button>
</form>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modalConfirm">
<div class="modal">
<h3>Confirmar Lançamento?</h3>
<div class="modal-data">
<div id="mDesc" style="font-weight: 700; font-size: 1rem; margin-bottom: 5px;"></div>
<div style="display: flex; justify-content: space-between; color: #64748b;">
<span id="mCat"></span>
<span id="mVal" style="color: var(--header); font-weight: 700;"></span>
</div>
</div>
<div class="modal-footer">
<button class="btn-confirm" id="btnConfirmFinal" onclick="finalizarLancamentoIA()">
<span id="labelConfirmIA">SIM, PODE LANÇAR</span>
</button>
<div class="btn-row">
<button class="btn-sec" style="background:#f1f5f9;" onclick="ajustarNoForm()">AJUSTAR</button>
<button class="btn-sec" style="background:#fee2e2; color:var(--danger);" onclick="resetarTudo()">CANCELAR</button>
</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
let dadosIA = null;
const moedaFmt = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
document.getElementById('textoIA').addEventListener('keydown', function(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); processarComIA(); }
});
// MÁSCARA R$ 0,00
function mascaraMoeda(i) {
let v = i.value.replace(/\D/g,'');
v = (v/100).toFixed(2) + '';
v = v.replace(".", ",");
v = v.replace(/(\d)(\d{3})(\d{3}),/g, "$1.$2.$3,");
v = v.replace(/(\d)(\d{3}),/g, "$1.$2,");
i.value = v !== "0,00" ? "R$ " + v : "";
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
let isRecording = false;
if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.lang = 'pt-BR';
recognition.continuous = false;
recognition.interimResults = false;
recognition.onstart = () => {
isRecording = true;
document.getElementById('micBtn').classList.add('active');
showMsg("Ouvindo...", "#4f46e5");
};
recognition.onend = () => {
isRecording = false;
document.getElementById('micBtn').classList.remove('active');
};
recognition.onresult = (event) => {
const transcricao = event.results[0][0].transcript;
document.getElementById('textoIA').value = transcricao;
showMsg("Entendido!", "#10b981");
// Opcional: Processar automaticamente após falar
setTimeout(processarComIA, 500);
};
recognition.onerror = (event) => {
isRecording = false;
document.getElementById('micBtn').classList.remove('active');
console.error("Erro Recognition:", event.error);
if (event.error === 'not-allowed') {
showMsg("Microfone bloqueado! Clique no cadeado na barra de endereços e permita o som.", "#ef4444");
} else if (event.error === 'network') {
showMsg("Erro de rede. Verifique sua conexão.", "#ef4444");
}
};
}
/**
* FUNÇÃO PRINCIPAL DE ATIVAÇÃO
* Resolve o problema de permissão no Android e Desktop
*/
async function toggleVoz() {
if (!recognition) return showMsg("Navegador sem suporte a voz", "#ef4444");
const btn = document.getElementById('micBtn');
if (btn.classList.contains('active')) {
recognition.stop();
return;
}
try {
recognition.start();
} catch (e) {
console.error("Erro ao iniciar:", e);
// Se o erro for 'not-allowed', o Google está bloqueando o Iframe.
if (e.name === 'NotAllowedError' || e.message.includes('not-allowed')) {
showMsg("Bloqueio de Segurança do Google. Use a Solução do Popup abaixo.", "#ef4444");
}
}
}
// --- LÓGICA DE PROCESSAMENTO IA ---
function processarComIA() {
const txtField = document.getElementById('textoIA');
const txt = txtField.value;
if (!txt) return;
const btn = document.getElementById('btnIA');
const label = document.getElementById('labelIA');
btn.disabled = true;
label.innerHTML = '<div class="spinner"></div> IA Analisando...';
google.script.run
.withSuccessHandler(res => {
btn.disabled = false;
label.innerHTML = 'Interpretar com IA';
// --- VALIDAÇÃO COM LIMPEZA DE CAMPO ---
if (res.erro) {
txtField.value = ''; // Limpa o campo se a IA não entender
if (res.erro === "não_é_despesa") {
showMsg("❌ Isso não parece ser uma despesa.", "#ef4444");
} else if (res.erro === "valor_ausente") {
showMsg("💰 Qual o valor? Não identifiquei o preço.", "#f59e0b");
}
return;
}
if (!res.valor || res.valor <= 0) {
txtField.value = ''; // Limpa se o valor vier zerado
showMsg("⚠️ Valor inválido para lançamento.", "#f59e0b");
return;
}
dadosIA = res;
abrirModal(res);
})
.withFailureHandler(() => {
showMsg("IA falhou!", "#ef4444");
label.innerHTML = 'Interpretar com IA';
btn.disabled = false;
})
.interpretarTextoComIA(txt);
}
function abrirModal(d) {
document.getElementById('mDesc').innerText = d.descricao;
document.getElementById('mVal').innerText = moedaFmt.format(d.valor);
document.getElementById('mCat').innerText = d.categoria;
document.getElementById('modalConfirm').style.display = 'flex';
}
function ajustarNoForm() {
document.getElementById('descricao').value = dadosIA.descricao;
const vField = document.getElementById('valor');
vField.value = dadosIA.valor.toFixed(2).replace('.', ',');
mascaraMoeda(vField);
document.getElementById('categoria').value = dadosIA.categoria;
document.getElementById('modalConfirm').style.display = 'none';
}
function resetarTudo() {
document.getElementById('despesaForm').reset();
document.getElementById('textoIA').value = '';
document.getElementById('modalConfirm').style.display = 'none';
dadosIA = null;
}
function finalizarLancamentoIA() {
const btn = document.getElementById('btnConfirmFinal');
const label = document.getElementById('labelConfirmIA');
btn.disabled = true;
label.innerHTML = '<div class="spinner"></div> Gravando...';
google.script.run
.withSuccessHandler(res => {
showMsg(res.message, "#10b981");
resetarTudo();
label.innerHTML = 'SIM, PODE LANÇAR';
btn.disabled = false;
})
.lancarDespesa(dadosIA);
}
// --- LANÇAMENTO MANUAL (COM SPINNER) ---
function validarELancarManual() {
const descField = document.getElementById('descricao');
const val = document.getElementById('valor').value;
const cat = document.getElementById('categoria').value;
if (!descField.value || !val || val === "R$ 0,00") return showMsg("Preencha Descrição e Valor!", "#f59e0b");
// Força a primeira letra maiúscula no manual também
descField.value = descField.value.charAt(0).toUpperCase() + descField.value.slice(1);
const btn = document.getElementById('btnManual');
const label = document.getElementById('labelManual');
// Ativa o visual de processamento
btn.disabled = true;
label.innerHTML = '<div class="spinner"></div> Gravando...';
google.script.run
.withSuccessHandler(res => {
showMsg(res.message, "#10b981");
resetarTudo();
label.innerHTML = 'LANÇAR AGORA';
btn.disabled = false;
})
.withFailureHandler(err => {
showMsg("Erro ao gravar", "#ef4444");
label.innerHTML = 'LANÇAR AGORA';
btn.disabled = false;
})
.lancarDespesa({
descricao: descField.value,
valor: val,
categoria: cat
});
}
let toastTimer = null;
function showMsg(t, c) {
const toast = document.getElementById('toast');
if (toastTimer) {
clearTimeout(toastTimer);
}
toast.innerText = t;
toast.style.backgroundColor = c;
toast.style.display = 'block';
let tempo = 3000;
if (c === "#ef4444") tempo = 5000;
if (c === "#f59e0b") tempo = 4000;
toastTimer = setTimeout(() => {
toast.style.display = 'none';
toastTimer = null; // Limpa a variável após esconder
}, tempo);
}
function abrirGravadorExterno() {
const largura = 380;
const altura = 500;
const esquerda = (screen.width / 2) - (largura / 2);
const topo = (screen.height / 2) - (altura / 2);
const win = window.open('', 'GravadorElite', `width=${largura},height=${altura},left=${esquerda},top=${topo}`);
const htmlElite = `
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<!-- CRUCIAL PARA MOBILE: Força o navegador a renderizar no tamanho correto na hora -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ouvindo...</title>
<style>
/* Força 100% da tela para evitar o erro de 'canto superior esquerdo' */
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: #1e293b;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container { text-align: center; width: 100%; }
.mic-box {
position: relative;
width: 100px; height: 100px;
margin: 0 auto 30px;
display: flex; align-items: center; justify-content: center;
}
.mic-button {
position: relative;
z-index: 5;
width: 85px; height: 85px;
background-color: #4f46e5;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
}
.mic-icon { width: 40px; height: 40px; fill: white; }
.pulse {
position: absolute;
width: 80px; height: 80px;
background-color: #4f46e5;
border-radius: 50%;
opacity: 0.6;
animation: ripple 2s infinite ease-out;
}
.pulse2 { animation-delay: 1s; }
@keyframes ripple {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.8); opacity: 0; }
}
h2 { font-size: 1.4rem; font-weight: 700; margin: 0 0 10px; color: #fff; }
p { color: #94a3b8; font-size: 1rem; margin: 0; padding: 0 30px; line-height: 1.5; min-height: 3em; }
.status-label {
position: absolute; bottom: 40px;
font-size: 0.75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 2px;
color: #818cf8;
}
</style>
</head>
<body>
<div class="container">
<div class="mic-box">
<div class="pulse"></div>
<div class="pulse pulse2"></div>
<div class="mic-button">
<svg class="mic-icon" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</div>
</div>
<h2>IA Elite Voz</h2>
<p id="feedback">Pode falar, estou ouvindo...</p>
<div class="status-label">Ouvindo Áudio</div>
</div>
<script>
const feedback = document.getElementById('feedback');
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
feedback.innerText = "Seu navegador não suporta voz.";
} else {
const recognition = new SpeechRecognition();
recognition.lang = 'pt-BR';
recognition.onresult = (event) => {
const texto = event.results[0][0].transcript;
feedback.innerText = '"' + texto + '"';
feedback.style.color = "#10b981";
if (window.opener) {
window.opener.postMessage({tipo: 'VOZ_CAPTADA', texto: texto}, '*');
setTimeout(() => window.close(), 1000);
}
};
recognition.onerror = () => {
feedback.innerText = "Erro ao captar áudio.";
feedback.style.color = "#ef4444";
setTimeout(() => window.close(), 2000);
};
recognition.onend = () => {
setTimeout(() => {
if(feedback.innerText === "Pode falar, estou ouvindo...") window.close();
}, 4000);
};
recognition.start();
}
<\/script>
</body>
</html>
`;
win.document.open();
win.document.write(htmlElite);
win.document.close();
}
// Escuta a resposta da janela de voz
window.addEventListener('message', function(event) {
if (event.data && event.data.tipo === 'VOZ_CAPTADA') {
document.getElementById('textoIA').value = event.data.texto;
showMsg("Voz captada!", "#10b981");
setTimeout(processarComIA, 500);
}
});
</script>
</body>
</html>
Precisa de uma solução sob medida?
Nós usamos cookies para analisar o tráfego do site e melhorar sua experiência. Ao clicar em 'Aceitar', você concorda com o uso de ferramentas de estatística, conforme detalhado em nossa Política de Privacidade.