--- name: vitruvio-criar-dashboard-mobile description: > Use when creating the MOBILE version of an existing Vitruvio indicator dashboard panel — a DesktopPanel shell (`-mobile.xml`) that delegates to `-desktop.xml`, plus the mobile detection/adaptation added to the desktop form: collapsible filter sidebar, stacked cards, dark/light theme toggle, and developer mobile preview. Triggers: "criar mobile do dashboard", "adicionar versão mobile ao indicador", "mobile preview do painel indicador", "CriarMobileIndicador". Called by vitruvio-criar-indicador-dashboard for the mobile part; can also be invoked directly with the target `-desktop.xml` path. For the desktop part use vitruvio-criar-dashboard-desktop. --- # Criar Mobile para Painel Indicador > Todas as mensagens ao usuário devem ser em Português. Você está criando a versão mobile de um painel Vitruvio existente. O padrão usado neste repositório é o **DesktopPanel delegado**: o `-mobile.xml` é um shell mínimo que delega toda a renderização ao `-desktop.xml`, que detecta o contexto mobile e adapta o layout sozinho. Receba o caminho do `-desktop.xml` alvo como argumento (ex: `panels/meu-painel/meu-painel-desktop.xml`). --- ## LEITURA OBRIGATÓRIA AO INICIAR — CONTEXT.md **Esta é a primeira coisa a fazer, antes de abrir qualquer outro arquivo.** Derive o diretório do painel a partir do argumento e leia: ``` panels//CONTEXT.md ``` **Se o CONTEXT.md existir:** - Leia-o completo primeiro. Ele contém os IDs reais dos campos, layouts, funções JS e o estado atual do painel — tudo que você precisa para criar o mobile corretamente sem reler o XML inteiro. - Use a seção **"IDs de layout e campo"** para preencher `forceLayoutsRender` e `forceFieldsRender` no `-mobile.xml`. - Use a seção **"Funções JavaScript principais"** para entender o que já existe e evitar duplicar. - Informe ao usuário: _"Li o contexto do painel. Vou criar o mobile com base nele."_ - Ao final, **atualize o CONTEXT.md**: marque a versão mobile como "concluída", adicione o caminho do `-mobile.xml` e registre a modificação no histórico. **Se o CONTEXT.md não existir:** - Informe ao usuário que não há arquivo de contexto para este painel. - Leia o `-desktop.xml` completo para extrair os IDs necessários. - Ao final, crie o CONTEXT.md completo (veja o formato na Fase 8 da skill **vitruvio-criar-dashboard-desktop**). --- ## Princípios obrigatórios — leia antes de qualquer coisa ### Nunca invente — pergunte quando tiver dúvida - Se não conhecer um componente, atributo ou comportamento da plataforma: **pergunte ao usuário** antes de inventar. Uma pergunta custa menos do que um bug em produção. - Baseie-se sempre no que já existe: leia o painel alvo e outros painéis deste repositório para entender o padrão adotado. O que já funciona aqui é a referência. - Não suponha assinaturas de serviços, nomes de atributos ou comportamentos de componentes que não estejam evidentes no código existente. ### Se o painel tem filtros acima do conteúdo — mova-os para uma sidebar colapsável Se o painel alvo tem filtros (combos, campos de texto, datas) posicionados **acima** do conteúdo principal (em linha no topo), **não deixe assim no mobile**. Filtros acima ocupam espaço valioso e poluem a tela. O padrão deste repositório é a **sidebar de filtros colapsável** — os dois painéis de referência canônicos estão empacotados junto com a skill desktop, leia-os antes de aplicar o padrão: - `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-contrato-desktop.xml` - `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-tecnicos-desktop.xml` O painel usa a global `dashLib` (carregada pelo `run()` desktop via `libService.loadScript('dashboard_ia')`) para tema/preferências — veja `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.js` para a API disponível. **É material de consulta apenas: nunca crie, copie ou registre um `scripts/dashboard_ia.js` no repositório de destino.** O padrão é: **Layout estrutural (XML)**: ``` rootLayout (VerticalLayout, 100% x 100%) ├── topBar (HorizontalLayout) — botão "◄ Filtros" + navegação + spacer + tema └── mainArea (HorizontalLayout, expandRatio=1) ├── filterBar (VerticalLayout, width=220px) — os filtros em coluna └── contentPanel (VerticalLayout, expandRatio=1) — o conteúdo principal ``` **Botão toggle no topBar**: ```xml ``` **No mobile**: o `filterBar` começa invisível (escondido no bloco `_mobileRender` do `run()`). O mobile tem seu próprio botão de filtros dentro da `filterBar` (`btnPreviewFiltros`) que só aparece no mobile — ele aciona o mesmo toggle, mantendo consistência sem adicionar nova lógica. **Botão de tema dentro da filterBar (obrigatório no mobile)**: adicione um `ButtonWidget id="btnThemeMob"` no fundo do `filterBar` (após o spacer Label), com `visible="false"`. No bloco `_mobileRender` do `run()`, torne-o visível. Ele deve chamar a mesma função `doToggleTheme` que o `btnTheme` do topBar — defina essa função no ScriptWidget InitScript, registre-a com `engine.setGlobalVariable('doToggleTheme', doToggleTheme)` no `init()`, e tenha ambos os botões chamando-a via `engine.getGlobalVariable('doToggleTheme')`. A função atualiza a caption de AMBOS os botões de tema (`btnTheme` e `btnThemeMob`), o CSS do filterBar e o HTML gerado. ```xml ``` ```javascript // No bloco _mobileRender do run(): var _btnThemeMob = engine.getWidgetController('btnThemeMob'); if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); } // Oculta btnTheme do topBar — no mobile apenas btnThemeMob é visível var _btnThemeTb = engine.getWidgetController('btnTheme'); if (_btnThemeTb) { _btnThemeTb.getButton().setVisible(false); } // doToggleTheme no ScriptWidget InitScript — atualiza os dois botões: function doToggleTheme() { var dashLib = engine.getGlobalVariable('dashLib'); var login = engine.getGlobalVariable('userLogin'); var current = String(engine.getGlobalVariable('theme') || 'dark'); var next = current === 'dark' ? 'light' : 'dark'; engine.setGlobalVariable('theme', next); try { var ui = Packages.com.vaadin.ui.UI.getCurrent(); if (dashLib && ui) { dashLib.applyTheme(next, ui.getPage(), null); } if (dashLib && login) { dashLib.savePreferences(login, { dark_mode: next === 'dark' }); } } catch(e) {} var bT = engine.getWidgetController('btnTheme'); if (bT) { bT.getButton().setCaption(next === 'dark' ? '☽' : '☀'); } var bM = engine.getWidgetController('btnThemeMob'); if (bM) { bM.getButton().setCaption(next === 'dark' ? '☽' : '☀'); } var _fb = engine.getLayout('filterBar'); if (_fb) { var fbComp = _fb.getRootComposition(); if (next === 'light') { fbComp.removeStyleName('dash-filterbar-dark'); fbComp.addStyleName('dash-filterbar-light'); } else { fbComp.removeStyleName('dash-filterbar-light'); fbComp.addStyleName('dash-filterbar-dark'); } } try { Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute( "var el=document.getElementById('meu-painel-main');" + "if(el){if('" + next + "'==='light')el.classList.add('theme-light');else el.classList.remove('theme-light');}" ); } catch(e2) {} var fn = engine.getGlobalVariable('renderView'); if (fn) fn(); } // No init(): engine.setGlobalVariable('doToggleTheme', doToggleTheme); ``` **Os campos de filtro dentro da filterBar são os do painel novo** — não copie os campos do `dashboard-contratos`. Leia o painel alvo, identifique seus filtros originais (cmbAno, cmbMes, txtCliente, etc.) e coloque-os dentro do `filterBar` como `VerticalLayout` com `spacing="true" margin="true"`. **`forceFieldsRender` no `-mobile.xml`**: inclua todos os campos de filtro da sidebar. Sem isso, `engine.getField('cmbAno')` falha no WebView mobile. **Campo de pesquisa/busca do desktop**: se o painel desktop tem um campo de texto para pesquisa (ex: filtrar por cliente, número, palavra-chave), ele **obrigatoriamente deve aparecer no mobile também**, dentro da filterBar. Nunca omita a pesquisa no mobile — o usuário mobile precisa dela tanto quanto o desktop. Se a pesquisa estiver inline acima da tabela no desktop, mova-a para dentro da `filterBar` no mobile (ou mantenha nos dois lugares). --- ### Siga o padrão do repositório Este repositório já tem painéis funcionando. Antes de criar algo novo: - Leia pelo menos um `-desktop.xml` existente deste repo para entender como estão estruturados os componentes, como são chamados os serviços, como é o estilo visual - Reutilize classes CSS já definidas (ex: `dash-kpi`, `dash-kpi-sm`, `mov-card`, `mobile-topbar`) em vez de criar novas - Reutilize padrões de `run()` / `init()` / `renderHtml()` que já existem no painel alvo ### Botões e campos padronizados - **Botões de ação** (`ButtonWidget`): use `style="DEFAULT"` para ações neutras, `style="GREEN"` para confirmar/salvar, `style="RED"` para cancelar/excluir. Nunca crie botões com HTML customizado quando um `ButtonWidget` padrão serve. - **Campos de filtro**: `ComboBox` para listas fechadas, `TextField` para texto livre, `DateField` para datas. Sempre com `id` único e `caption` em português. - **Ícones**: use os já presentes no painel alvo. Se precisar de novo ícone, use os codepoints já usados no repositório (ex: `📱` para mobile, `✕` para fechar) — não invente. - **Labels de valor monetário**: sempre use a função de formatação já existente no painel (ex: `formatBRL(valor)`) — não crie nova. ### JavaScript — ES5 Rhino somente Nunca use ES6+. Veja o que é proibido e o que usar no lugar: ```javascript // PROIBIDO const x = 1; // use: var x = 1; let y = 2; // use: var y = 2; () => {}; // use: function() {} `texto ${var}`; // use: 'texto ' + var const { a } = obj; // use: var a = obj.a; class Foo {} // use: function Foo() {} async/await // use: callbacks import/export // não disponível for (const x of arr) // use: for (var i = 0; i < arr.length; i++) ``` --- --- ## Passo 1 — Confirmar repositório e ler o painel alvo ```bash ls vitruvio.json 2>/dev/null && echo "OK" || echo "NOT_A_VITRUVIO_REPO" ``` Se `NOT_A_VITRUVIO_REPO`, pare e peça para o usuário entrar no diretório correto. Leia o `-desktop.xml` alvo **completo** antes de escrever qualquer linha. Leia também pelo menos um outro painel do repositório para entender os padrões já adotados. Só depois prossiga. Leia o `-desktop.xml` alvo para entender: - O `formKey` do painel - Os layouts declarados (`id` de cada `VerticalLayout`, `HorizontalLayout`, `Panel`, etc.) - Os campos de filtro (`ComboBox`, `TextField`, etc.) - A estrutura do `initScript` / `run()` / `init()` do ScriptWidget - O que é renderizado em HTML (ScriptWidget que chama `renderHtml`) Leia o `vitruvio.json` para identificar a entrada do painel (`key`, `forms`). --- ## Passo 2 — Criar o `-mobile.xml` Crie `panels//-mobile.xml`. Ele é um shell que delega ao desktop: ```xml
``` **`forceLayoutsRender`**: liste todos os layouts do desktop que precisam existir no DOM móvel (ex: `topBar, filterBar, mainArea, contentPanel, panelRoot`). Leia o XML do desktop para identificá-los. **`forceFieldsRender`**: liste os campos de filtro que o script usa (ex: `cmbAno, cmbMes, cmbOrdem`). Sem eles, `engine.getField('cmbAno')` falha no mobile. **`height="100%"` no `VerticalLayout` e no `DesktopPanel`**: os dois níveis do shell usam `100%` — a altura real vem do container mobile que envolve este `-mobile.xml`. Um valor fixo grande (ex: `3000px`/`700px`) já foi usado aqui para forçar os layouts filhos a calcularem porcentagem, mas isso quebrava a tela (espaço em branco/corte); com `100%` nos dois níveis do shell a altura se propaga corretamente do container pai, sem precisar desse artifício. --- ## Passo 3 — Atualizar vitruvio.json No `vitruvio.json`, na entrada do painel: - Adicione `"mobile": "panels//-mobile.xml"` dentro de `"forms"` - Defina `"showInMobileList": true` ```json { "key": "", "forms": { "desktop": "panels//-desktop.xml", "mobile": "panels//-mobile.xml" }, "showInMobileList": true } ``` --- ## Passo 4 — Adaptar o `-desktop.xml` Esta é a parte principal. O desktop precisa detectar o contexto mobile e adaptar o HTML gerado. ### 4.1 — Bloco de inicialização mobile no `run()` Dentro do `run()` (initScript), após o bloco de CSS principal, adicione a detecção mobile: ```javascript if (engine.isGlobalVariableSet('_mobileRender')) { engine.setGlobalVariable('isMobile', true); var _rootLayout = engine.getLayout('rootLayout'); if (_rootLayout) { var _rootLayoutComp = _rootLayout.getRootComposition(); // 700px é só o placeholder inicial — o server não sabe a altura da tela do // cliente. O script abaixo troca por um valor proporcional a window.screen.height // assim que a página carrega, ANTES do colapso de renderHtml (ver seção 4.6). _rootLayoutComp.setHeight("700px"); _rootLayoutComp.addStyleName('mobile-root-frame'); page.getJavaScript().execute( "(function(){" + "var el=document.querySelector('.mobile-root-frame');" + "if(!el){return;}" + "var sh=window.screen.height;" + "var byMargin=sh-220;" + "var byRatio=Math.round(sh*0.80);" + "var h=Math.max(320,Math.min(byMargin,byRatio));" + "el.style.height=h+'px';" + "})();" ); } // Aplica estilo de topbar mobile ao topBar se existir var _topBar = engine.getLayout('topBar'); if (_topBar) { _topBar.getRootComposition().addStyleName('mobile-topbar'); } // Esconde filterBar no mobile (filtros ficam em outro fluxo ou botão) var _filterBar = engine.getLayout('filterBar'); if (_filterBar) { _filterBar.getRootComposition().setVisible(false); } // Exibe btnThemeMob (dentro da filterBar) e oculta btnTheme (topBar) // Sem isso o usuário vê dois botões de tema simultaneamente no mobile var _btnThemeMobR = engine.getWidgetController('btnThemeMob'); if (_btnThemeMobR) { _btnThemeMobR.getButton().setVisible(true); } var _btnThemeTbR = engine.getWidgetController('btnTheme'); if (_btnThemeTbR) { _btnThemeTbR.getButton().setVisible(false); } // CSS específico mobile adicionado via page.getStyles().add() page.getStyles().add( // Tabelas/grids mobile: altura fixa com scroll interno ".mobile-view .mov-scroll { height:520px !important; overflow-y:auto !important;" + " -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " // Adicione aqui outros CSS mobile específicos deste painel ); } ``` > **Se o painel alvo tem o assistente de IA "Pietro"** (sidebar `chatBar`, veja a seção opcional > 7.11 da skill **vitruvio-criar-dashboard-desktop**): no mesmo bloco `_mobileRender`, oculte > `btnToggleChat` e mostre `btnChatFabMobile` — mesmo tratamento dado a `btnTheme`/`btnThemeMob` > acima. Replique também em `enterMobilePreview`/`exitMobilePreview` (Passo 5.3). Se o painel não > tiver esses IDs, ignore — é um add-on opcional, não parte obrigatória do padrão. ### 4.2 — Variável `mobileClass` no HTML gerado Em toda função que gera HTML (ex: `desenharDashboard`, `renderView`, etc.), adicione: ```javascript var mobileClass = (engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender')) ? ' mobile-view' : ''; var html = '
'; ``` Isso permite que todo o CSS mobile use seletores `.mobile-view .classe` sem afetar o desktop. ### 4.3 — Layouts horizontais → cards verticais no mobile **Regra de ouro**: no mobile NUNCA coloque duas informações lado a lado. Tudo em coluna única. **Desktop** (layout horizontal): ```javascript html += '
'; html += '
R$ 100.000
'; html += '
R$ 50.000
'; html += '
'; ``` **Mobile** (detectar e converter para vertical): ```javascript if (mobileClass) { html += '
'; html += '
LocaçãoR$ 100.000
'; html += '
ServiçoR$ 50.000
'; html += '
'; } else { // layout desktop original } ``` ### 4.4 — Tabelas (grids) no mobile → cards empilhados No desktop: `` com colunas. No mobile: cada linha vira um card com os campos empilhados: ```javascript if (mobileClass) { html += '
'; for (var i = 0; i < linhas.length; i++) { var l = linhas[i]; html += '
'; html += '
'; html += ' ' + l.nome + ''; html += '
'; html += '
' + l.codigo + ' · ' + l.tipo + '
'; html += '
'; html += '
Valor' + formatBRL(l.valor) + '
'; html += '
Status' + l.status + '
'; html += '
'; html += '
'; } html += '
'; } else { // tabela desktop } ``` ### 4.5 — CSS mobile obrigatório No `addCss()` (ou no CSS principal adicionado em `run()`), inclua os seletores mobile: > **CRÍTICO:** Todo CSS de scroll mobile **deve estar em `addCss()`** — nunca apenas no bloco `_mobileRender`. Se ficar só em `_mobileRender`, o preview mobile do desenvolvedor não funciona (porque `isMobile=true` mas `_mobileRender` não está setado). ```javascript // ── Scroll containers no mobile — OBRIGATÓRIO em addCss() ── ".mobile-view .mov-scroll { max-height:520px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " + ".mobile-view .tec-mob-scroll { max-height:420px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " + ".mobile-view .hbar-scroll { max-height:280px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; } " + // Tamanhos de fonte mobile — NUNCA menor que o desktop, sempre maior ".mobile-view .dash-kpi-sm .dash-kpi-label { font-size:13px !important; } " + ".mobile-view .dash-kpi-sm .dash-kpi-value { font-size:17px !important; } " + // Cards de movimentação/dados mobile ".mov-card { background:#1a2733; border-radius:6px; padding:12px 16px; margin-bottom:8px; } " + ".mov-card-cliente { font-weight:bold; color:#fff; font-size:13px; } " + ".mov-card-meta { font-size:11px; color:#8899aa; margin-bottom:8px; } " + ".mov-card-values { display:flex; flex-wrap:wrap; gap:6px; } " + ".mov-card-val { flex:1; min-width:80px; background:#0f1923; border-radius:4px; padding:6px 8px; } " + ".mov-card-lbl { display:block; font-size:10px; color:#8899aa; margin-bottom:2px; } " + // Mobile: fontes maiores nos cards ".mobile-view .mov-card-cliente { font-size:15px !important; } " + ".mobile-view .mov-card-meta { font-size:13px !important; } " + ".mobile-view .mov-card-lbl { font-size:12px !important; } " + ``` **Classes de scroll padrão:** | Classe | Altura | Uso | |---|---|---| | `.mov-scroll` | 520px | Container da lista de movimentação (cards ou tabela) | | `.tec-mob-scroll` | 420px | Container de lista de cards de técnicos/ranking | | `.hbar-scroll` | max 280px | Container de gráfico de barras horizontais (svgHBar) | **Padrão de envolvimento obrigatório no mobile:** ```javascript // ── Movimentação: barra de pesquisa FORA do scroll, cards DENTRO ── html += ''; // fica fixo no topo html += '
'; // scroll interno for (var i = 0; i < linhas.length; i++) { html += '
...
'; } if (linhas.length === 0) html += '
Sem dados
'; html += '
'; // fecha mov-scroll // ── Lista de técnicos/ranking: header fora, cards dentro ── html += '
...header com botões de sort...
'; html += '
'; html += '
'; for (var i = 0; i < tecArr.length; i++) { html += '
...
'; } html += '
'; // fecha tec-mob-list html += '
'; // fecha tec-mob-scroll // ── Gráficos svgHBar: sempre dentro de hbar-scroll ── html += '
'; html += '
Título
'; html += '
' + svgHBar(dados, '#4a9edd') + '
'; html += '
'; ``` **Tamanhos mínimos de fonte mobile:** | Tipo de dado | Desktop | Mobile mínimo | |---|---|---| | Label/rótulo | 10–12px | 12–13px | | Valor principal | 13–16px | 16–18px | | Título de seção | 14–16px | 18–20px | | Texto de detalhe | 11–12px | 13–14px | ### 4.6 — renderHtml: tratar ScriptWidget para mobile e preview **Padrão real (confirmado nos dois painéis de referência):** `renderHtml` distingue **preview de dev** (`isMobile` setado, `_mobileRender` não) do resto — só o preview usa altura natural via `base.setHeight(null)`; mobile real (`_mobileRender`) e desktop normal sempre usam `setSizeFull()`. Não existe nenhuma técnica de "colapsar um elemento de `700px`" — isso é uma versão obsoleta. Depois do render, o único ajuste feito por JS é liberar `overflow:visible` nos containers pais intermediários até encontrar o `.mobile-root-frame` (cuja altura já foi fixada uma única vez no bloco `_mobileRender` do `run()`, via `window.screen.height` — ver seção 4.1), para que o scroll externo não "arraste" a `topBar` junto: ```javascript function renderHtml(html) { var comp = components.html(html); // isPreviewMode: preview de dev (isMobile=true SEM _mobileRender). Mobile WebView real // (_mobileRender=true) e desktop normal usam setSizeFull() — só o preview precisa de altura // natural para o Panel (contentPanel) enxergar o overflow real e rolar. var isPreviewMode = engine.isGlobalVariableSet('isMobile') && !engine.isGlobalVariableSet('_mobileRender'); if (isPreviewMode) { try { base.setHeight(null); } catch(e) {} comp.setWidth("100%"); } else { comp.setSizeFull(); } base.removeAllComponents(); base.addComponent(comp); if (!isPreviewMode) { base.setExpandRatio(comp, 1.0); } Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute( "setTimeout(function(){" + "if(typeof meuPainelApplyTheme==='function')meuPainelApplyTheme();" + // NÃO redimensionar .mobile-root-frame para caber o conteúdo aqui — sua altura é fixa // (definida uma única vez no _mobileRender via window.screen.height). Só libera // overflow:visible nos pais intermediários para o scroll externo funcionar sem // "arrastar" a topBar junto. "var el=document.getElementById('meu-painel-main');" + "if(el){" + "var p=el,n=0;" + "while(p&&n<30){p=p.parentElement;n++;" + "if(!p){break;}" + "if(p.classList&&p.classList.contains('mobile-root-frame')){break;}" + "var cs=window.getComputedStyle(p);" + "if(cs&&(cs.overflowY==='auto'||cs.overflowY==='scroll'||cs.overflowY==='hidden')){p.style.overflowY='visible';}" + "if(cs&&(cs.overflow==='auto'||cs.overflow==='scroll'||cs.overflow==='hidden')){p.style.overflow='visible';}" + "}" + "}" + "},400);" ); } ``` --- ## Passo 5 — Sistema de Preview Mobile (para grupo vi_developer) Adicione ao `-desktop.xml` um botão de preview que simula o mobile diretamente no desktop. Isso permite testar sem abrir o WebView mobile. ### 5.1 — Botão no topBar (XML) ```xml function run() { var fn = engine.getGlobalVariable('enterMobilePreview'); if (fn) fn(); } function run() { var fn = engine.getGlobalVariable('exitMobilePreview'); if (fn) fn(); } ``` ### 5.2 — CSS do preview Adicione ao CSS principal: ```javascript ".preview-mobile-frame { margin:0 auto !important; border:2px solid #4a9edd !important;" + " border-radius:12px !important; box-shadow:0 0 40px rgba(74,158,221,0.25) !important; } " + ".preview-exit-btn { position:fixed !important; top:10px !important; right:12px !important;" + " z-index:99999 !important; background:#e74c3c !important; border:none !important;" + " border-radius:6px !important; padding:7px 16px !important; font-weight:bold !important;" + " box-shadow:0 2px 12px rgba(0,0,0,0.4) !important; cursor:pointer !important; } " + ".preview-exit-btn .v-button-caption { color:#fff !important; font-size:12px !important; } " ``` ### 5.3 — Funções enterMobilePreview / exitMobilePreview **Padrão real (confirmado nos dois painéis de referência):** o preview usa altura **natural**, não fixa — `base.setHeight(null)` faz o `ScriptWidget` crescer ao tamanho do conteúdo, e o `Panel` (`contentPanel`) que o envolve enxerga o overflow real e rola sozinho. Não há valor fixo (nem `700px` nem `3000px`) nem colapso via JS aqui — essa técnica é só para o `_mobileRender` real (placeholder `700px` recalculado por `window.screen.height`, seção 4.1), que existe porque ali sim há uma tela de celular real para medir. No preview, que roda no navegador desktop do dev, altura natural é suficiente e mais simples. Adicione no ScriptWidget, antes do `init()`: ```javascript function enterMobilePreview() { engine.setGlobalVariable('isMobile', true); // base com height null → cresce ao tamanho do conteúdo → Panel enxerga overflow e rola try { base.setHeight(null); } catch(e) {} var _rl = engine.getLayout('rootLayout'); if (_rl) { var _rlComp = _rl.getRootComposition(); _rlComp.setWidth("375px"); _rlComp.addStyleName('preview-mobile-frame'); } var _tBar = engine.getLayout('topBar'); if (_tBar) _tBar.getRootComposition().addStyleName('mobile-topbar'); // Salva e esconde filterBar var _fBar = engine.getLayout('filterBar'); if (_fBar) { var _fBarComp = _fBar.getRootComposition(); engine.setGlobalVariable('_prevFilterBarVisible', _fBarComp.isVisible()); _fBarComp.setVisible(false); } var _btnDev = engine.getWidgetController('btnDevPreview'); if (_btnDev) _btnDev.getButton().setVisible(false); var _btnExit = engine.getWidgetController('btnExitPreview'); if (_btnExit) { _btnExit.getButton().setVisible(true); _btnExit.getButton().addStyleName('preview-exit-btn'); } // Exibe btnThemeMob e oculta btnTheme — igual ao _mobileRender real var _btnThMob = engine.getWidgetController('btnThemeMob'); if (_btnThMob) _btnThMob.getButton().setVisible(true); var _btnThTb = engine.getWidgetController('btnTheme'); if (_btnThTb) _btnThTb.getButton().setVisible(false); renderView(); // re-renderiza com mobile-view ativo } function exitMobilePreview() { engine.unsetGlobalVariable('isMobile'); // Restaura base para height 100% (modo desktop normal) try { base.setHeight("100%"); } catch(e) {} var _rl = engine.getLayout('rootLayout'); if (_rl) { var _rlComp = _rl.getRootComposition(); _rlComp.setWidth("100%"); _rlComp.removeStyleName('preview-mobile-frame'); } var _tBar = engine.getLayout('topBar'); if (_tBar) _tBar.getRootComposition().removeStyleName('mobile-topbar'); // Restaura filterBar — usa loose equality (== ) para coerção de Java Boolean var _fBar = engine.getLayout('filterBar'); if (_fBar) { var _wasVisible = engine.getGlobalVariable('_prevFilterBarVisible'); _fBar.getRootComposition().setVisible(_wasVisible == true); engine.unsetGlobalVariable('_prevFilterBarVisible'); } var _btnExit = engine.getWidgetController('btnExitPreview'); if (_btnExit) { _btnExit.getButton().removeStyleName('preview-exit-btn'); _btnExit.getButton().setVisible(false); } // Restaura btnTheme do topBar e oculta btnThemeMob ao sair do preview var _btnThMobExit = engine.getWidgetController('btnThemeMob'); if (_btnThMobExit) _btnThMobExit.getButton().setVisible(false); var _btnThTbExit = engine.getWidgetController('btnTheme'); if (_btnThTbExit) _btnThTbExit.getButton().setVisible(true); // Reexibe botão preview se o usuário for developer var _isDev = engine.getGlobalVariable('isDeveloper'); var _btnDev = engine.getWidgetController('btnDevPreview'); if (_btnDev) _btnDev.getButton().setVisible(!!_isDev); renderView(); } ``` ### 5.4 — Checar grupo vi_developer e exibir botão no init() No `init(mapa)` ou no `run()`, após a inicialização principal: ```javascript // Verifica acesso de desenvolvedor (vi_developer) try { var _dbLib = libService.loadScript('db'); var _banco = new _dbLib(_dbLib.VITRUVIO_DATASOURCE); var _devRow = _banco.queryRow( "SELECT COUNT(*) AS CNT FROM nauth.usuario u " + "INNER JOIN nauth.usuario_grupo ug ON u.usuario_id = ug.usuario_fk " + "INNER JOIN nauth.grupo g ON ug.grupo_fk = g.grupo_id " + "WHERE u.login = '" + engine.getLoggedUser().getLogin() + "' " + "AND g.sigla = 'vi_developer'" ); if (_devRow && String(_devRow.CNT) !== '0') { engine.setGlobalVariable('isDeveloper', true); var _btnDev = engine.getWidgetController('btnDevPreview'); if (_btnDev) _btnDev.getButton().setVisible(true); } } catch(e) {} // Registra funções de preview como variáveis globais (acessíveis pelo XML dos botões) engine.setGlobalVariable('enterMobilePreview', enterMobilePreview); engine.setGlobalVariable('exitMobilePreview', exitMobilePreview); ``` --- ## Passo 6 — Armadilhas conhecidas (não repita estes erros) ### Java Boolean vs JS boolean `engine.getGlobalVariable()` retorna `Java Boolean`, não JS primitive: ```javascript // ERRADO — Java Boolean.FALSE !== JS false (tipos diferentes, strict equality falha) if (_wasVisible !== false) { ... } // CERTO — loose equality funciona com Java Boolean if (_wasVisible == true) { ... } // OU if (!!_wasVisible) { ... } ``` ### setExpandRatio antes de addComponent ```javascript // ERRADO — comp não é filho de base ainda, Vaadin lança IllegalArgumentException comp.setSizeFull(); base.setExpandRatio(comp, 1.0); // ← ERRO: comp não está em base base.addComponent(comp); // CERTO — setExpandRatio sempre APÓS addComponent comp.setSizeFull(); base.removeAllComponents(); base.addComponent(comp); base.setExpandRatio(comp, 1.0); // ← OK: comp já é filho ``` ### base.setHeight(null) no preview — por que funciona `enterMobilePreview` põe `base` (o container do `ScriptWidget`) em altura natural com `base.setHeight(null)` — sem isso, o container mantém a altura da viewport inteira e o `Panel` (`contentPanel`) não enxerga o conteúdo real para rolar. No `_mobileRender` real esse mesmo `base.setHeight(null)` **não** é usado — lá o `renderHtml` sempre chama `comp.setSizeFull()`, porque quem controla a altura do frame é o cálculo de `window.screen.height` no `rootLayout` (seção 4.1), não o `base`. ### inline style sobrescreve class CSS Se o HTML gerado tem `style="font-size:12px"` no elemento, CSS de classe não sobrescreve (exceto com `!important`). Mude o valor diretamente na geração do HTML para contexto mobile: ```javascript var fontSize = mobileClass ? '14px' : '12px'; html += '
Texto
'; ``` ### Redimensionar o frame mobile a cada render — não faça isso Uma versão antiga recalculava a altura do `.mobile-root-frame` para `el.getBoundingClientRect().bottom` a cada `renderHtml`. Isso fazia o frame crescer para caber TODO o conteúdo (não só o viewport), transformando `topBar` + conteúdo num único bloco comprido que a página externa rolava inteiro — "arrastando" a `topBar` junto. A altura do `.mobile-root-frame` é definida **uma única vez** no bloco `_mobileRender` do `run()` (via `window.screen.height`, seção 4.1) e deve permanecer fixa; `renderHtml` só libera `overflow:visible` nos pais intermediários (seção 4.6), nunca redimensiona o frame em si. --- ## Passo 7 — Checklist de entrega Antes de declarar pronto, confirme: - [ ] Leu o `-desktop.xml` alvo e ao menos um outro painel do repositório antes de escrever qualquer coisa - [ ] Tudo que foi feito tem referência no código existente do repositório — nada inventado - [ ] Não usou nenhuma feature ES6+ (sem `const`, `let`, arrow functions, template literals, destructuring, `class`, `async/await`) - [ ] Botões usam `style` padrão (`DEFAULT`, `GREEN`, `RED`) — sem HTML customizado para botões - [ ] Reutilizou funções de formatação já existentes no painel (ex: `formatBRL`) — sem duplicar - [ ] Reutilizou classes CSS já existentes — sem criar novas desnecessariamente - [ ] `-mobile.xml` criado com `DesktopPanel` apontando para o `panelKey` correto - [ ] `forceLayoutsRender` lista todos os layouts usados pelo desktop - [ ] `forceFieldsRender` lista todos os campos de filtro acessados via `engine.getField()` - [ ] `vitruvio.json`: `"mobile"` adicionado em `"forms"` e `"showInMobileList": true` - [ ] `run()` do desktop detecta `_mobileRender` e adapta rootLayout + filterBar + CSS - [ ] No `_mobileRender` real: `rootLayout` recebe `setHeight("700px")` + `addStyleName('mobile-root-frame')`, seguido de `page.getJavaScript().execute(...)` calculando a altura final a partir de `window.screen.height` (clamp entre 320px e `min(screen.height-220, screen.height*0.80)`) — o `700px` é só o placeholder inicial, não a altura final do mobile real - [ ] HTML gerado usa `mobileClass` e layouts verticais no mobile - [ ] Tabelas/grids do desktop viram cards empilhados no mobile - [ ] Fontes mobile respeitam os tamanhos mínimos (labels ≥ 12px, valores ≥ 16px) - [ ] `renderHtml` distingue preview (`isMobile` sem `_mobileRender`) de mobile real/desktop: só o preview usa `base.setHeight(null)` + `comp.setWidth("100%")`; os outros dois usam `comp.setSizeFull()` + `base.setExpandRatio(comp, 1.0)` - [ ] `enterMobilePreview` usa `base.setHeight(null)` (altura natural) — não copia o `700px` fixo do `_mobileRender` real, que só faz sentido para tela de celular - [ ] `exitMobilePreview` restaura `base.setHeight("100%")` - [ ] JS pós-render (setTimeout 400ms mínimo) só libera `overflow:visible` nos pais até `.mobile-root-frame` — nunca redimensiona o frame em si - [ ] `setExpandRatio` é chamado APÓS `addComponent` - [ ] Funções `enterMobilePreview` / `exitMobilePreview` registradas como global vars - [ ] Botão `btnDevPreview` visível somente para `vi_developer` - [ ] `btnThemeMob` adicionado como **primeiro filho** do `filterBar`, `visible="false"` no XML, visível no bloco `_mobileRender` - [ ] No bloco `_mobileRender` e em `enterMobilePreview`: `btnTheme` (topBar) oculto via `setVisible(false)` — evita dois botões de tema simultâneos - [ ] No `exitMobilePreview`: `btnThemeMob` oculto e `btnTheme` restaurado via `setVisible(true)` - [ ] `doToggleTheme` definido no ScriptWidget, registrado no `init()`, chamado por `btnTheme` e `btnThemeMob` - [ ] `doToggleTheme` atualiza caption de ambos os botões de tema - [ ] Comparação de Java Boolean usa `==` (loose equality), nunca `===` - [ ] CSS de scroll mobile (`.mov-scroll`, `.tec-mob-scroll`, `.hbar-scroll`) está em `addCss()` — **não apenas** no bloco `_mobileRender` - [ ] Lista de cards de movimentação mobile envolvida em `
` - [ ] Lista de técnicos/ranking mobile envolvida em `
` - [ ] Gráficos svgHBar no mobile envolvidos em `
` --- ## Referência rápida de classes CSS mobile | Classe | Uso | |---|---| | `.mobile-view` | Aplicada ao `id` raiz do HTML gerado quando em modo mobile | | `.mobile-topbar` | Estilo do topBar no mobile (botões menores, compactos) | | `.preview-mobile-frame` | Borda azul que indica o frame de preview 375px | | `.preview-exit-btn` | Botão "✕ Sair Preview" flutuante fixed no canto superior direito | | `.mov-scroll` | Container da lista de movimentação (cresce com o conteúdo, scroll acima de 520px) | | `.tec-mob-scroll` | Container de lista de técnicos/ranking (cresce com o conteúdo, scroll acima de 420px) | | `.hbar-scroll` | Container de gráfico svgHBar (max 280px + scroll) | | `.mov-cards` | Container de cards empilhados (substitui a table no mobile) | | `.mov-card` | Card individual de cada linha da tabela | | `.mov-card-cliente` | Nome/título principal do card (font-size ≥ 15px mobile) | | `.mov-card-meta` | Informações secundárias (contrato, tipo, período) | | `.mov-card-values` | Flex wrap com os valores numéricos do card | | `.mov-card-val` | Célula individual de valor dentro do card | | `.mov-card-lbl` | Rótulo do valor (font-size ≥ 12px mobile) |