From ad09c53a5d445e3b18b91ed4c231429f39d7a9b1 Mon Sep 17 00:00:00 2001 From: FelipeCN <57776624+felipecaninnovaes@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:58:54 -0300 Subject: [PATCH] feat: implement UI redesign for Comparador de Notas - Introduced a new theme for the application based on dark navy aesthetics. - Created design tokens for colors, spacings, and border radii. - Developed a theme module in Rust to manage styles for various UI components. - Updated multiple screens and components to align with the new design, including cards, buttons, badges, and progress bars. - Enhanced user experience with improved layouts and visual elements across the application. --- UI_REDESIGN.md | 291 +++++++++++++++ src/main.rs | 1 + src/ui/app.rs | 42 ++- src/ui/components/modal.rs | 106 ++++-- src/ui/components/paginacao.rs | 18 +- src/ui/components/tabela_preview.rs | 117 ++++-- src/ui/mod.rs | 1 + src/ui/screens/configuracao_colunas.rs | 480 ++++++++++++++---------- src/ui/screens/import.rs | 168 +++++++-- src/ui/screens/layouts.rs | 81 +++-- src/ui/screens/resultado.rs | 283 +++++++++++---- src/ui/screens/selecionar_aba.rs | 181 +++++++--- src/ui/theme.rs | 481 +++++++++++++++++++++++++ 13 files changed, 1820 insertions(+), 430 deletions(-) create mode 100644 UI_REDESIGN.md create mode 100644 src/ui/theme.rs diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md new file mode 100644 index 0000000..130d228 --- /dev/null +++ b/UI_REDESIGN.md @@ -0,0 +1,291 @@ +# UI Redesign — Comparador de Notas + +**Data:** 04/03/2026 +**Branch:** `change-ui` +**Base:** iced 0.13.1 (Elm architecture) +**Referência visual:** `ui-ideia/mockup.html` + `ui-ideia/design_tokens.json` + +--- + +## 1. Objetivo + +Aplicar o visual do mockup (tema dark navy, cards, badges coloridos, progress bars) a todas as telas da aplicação, mantendo a lógica de negócio e a arquitetura Elm intocadas. + +--- + +## 2. Design Tokens (mapeados para Rust) + +### Paleta de cores + +| Token | Hex | Uso | +|------------------------|-----------|---------------------------------------| +| `BG` | `#0F172A` | Fundo geral da janela | +| `SURFACE` | `#1E293B` | Cards / containers primários | +| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela| +| `BORDER` | `#334155` | Bordas de inputs e cards | +| `TEXT` | `#F1F5F9` | Texto principal | +| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders, texto muted | +| `TEXT_MUTED` | `#64748B` | Texto desabilitado | +| `PRIMARY` | `#3B82F6` | Botões primários, links, step ativo | +| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários | +| `SUCCESS` | `#22C55E` | Badge OK, progress bar ≥ 90% | +| `WARNING` | `#F59E0B` | Badge Faltante, progress bar 60–89% | +| `ERROR` | `#EF4444` | Badge Duplicada, progress bar < 60% | +| `OVERLAY` | rgba(0,0,0,0.6) | Fundo do modal | + +### Espaçamentos + +| Token | px | +|-------|----| +| `XS` | 4 | +| `SM` | 8 | +| `MD` | 12 | +| `LG` | 16 | +| `XL` | 24 | +| `XXL` | 32 | + +### Border radius + +| Token | px | +|--------|----| +| `SM` | 4 | +| `MD` | 6 | +| `LG` | 8 | + +--- + +## 3. Arquivo de tema: `src/ui/theme.rs` + +Módulo responsável por expor: + +- `PALETA`: constantes `Color` para todas as cores acima +- Funções de estilo para `container::Style`, `button::Style`, `text_input::Style`, `progress_bar::Style` +- Nenhuma lógica de negócio — apenas aparência + +### Estratégia de tema iced + +```rust +// main.rs — encadear .theme() +iced::application(...) + .theme(|_app, _| tema_dark()) + .run_with(App::new) + +// theme.rs +pub fn tema_dark() -> iced::Theme { + iced::Theme::custom("dark".to_string(), iced::theme::Palette { + background: hex("#0F172A"), + text: hex("#F1F5F9"), + primary: hex("#3B82F6"), + success: hex("#22C55E"), + danger: hex("#EF4444"), + }) +} +``` + +Widgets que precisam de aparência customizada além da paleta (cards, badges) recebem closure `.style(|theme| ...)` inline ou via função helper em `theme.rs`. + +--- + +## 4. Componentes visuais novos / modificados + +### 4.1 Card container + +Container com background `SURFACE`, borda `BORDER` 1px, radius `LG` (8px), padding `LG` (16px). + +```rust +// theme.rs +pub fn card(theme: &iced::Theme) -> container::Style { ... } +pub fn card_secondary(theme: &iced::Theme) -> container::Style { ... } +``` + +### 4.2 Botão primary + +Background `PRIMARY`, texto `TEXT`, radius `MD` (6px), sem borda. +Hover: background `PRIMARY_HOVER`. + +### 4.3 Botão secondary / ghost + +Background `SURFACE_2`, texto `TEXT`, radius `MD`. + +### 4.4 Botão danger + +Background semi-transparente `ERROR` (20% alpha), texto `ERROR`, radius `MD`. + +### 4.5 Badge de status (inline) + +Container com padding `[2, 8]`, radius `SM` (4px), background 20% alpha da cor semântica. +Implementado como `container(text(...).size(12))` com style closure. + +| Status | Cor texto | Background alpha | +|------------|------------|-----------------| +| OK | `SUCCESS` | 20% | +| Faltante | `WARNING` | 20% | +| Duplicada | `ERROR` | 20% | + +### 4.6 Progress bar por série + +Componente `progress_bar` nativo do iced com style closure que escolhe cor baseada no percentual: +- ≥ 90% → `SUCCESS` +- 60–89% → `WARNING` +- < 60% → `ERROR` + +Background da trilha: `#111827` (mais escuro que SURFACE). + +### 4.7 Stat cards (tela de resultado) + +Row de 3 cards com número grande colorido + label. Componente reutilizável `stat_card(valor, label, cor)`. + +### 4.8 Breadcrumb + +Row no topo da janela (exceto Layouts e Analisando). Steps separados por `›`. Background `SURFACE`, padding `[8, 16]`, borda inferior 1px `BORDER`. + +| Estado do step | Cor | Tamanho | +|---------------|-------------|---------| +| Ativo | `PRIMARY` | 14px | +| Concluído | `TEXT_SECONDARY` | 13px | +| Futuro | `TEXT_MUTED` | 13px | + +--- + +## 5. Telas — mudanças por arquivo + +### 5.1 `screens/import.rs` + +**Atual:** Coluna plana com título, row de arquivo e row de layout. + +**Novo:** +- Card central (max-width 600px) centrado na tela +- Área de drop zone estilizada com borda tracejada `BORDER`, radius `LG`, padding `XL` +- Ícone `📂` grande + texto instrucional +- Nome do arquivo selecionado com truncamento +- Seção de layout com separador visual +- Botão primary "▶ Configurar Colunas" ocupando largura do card + +### 5.2 `screens/selecionar_aba.rs` + +**Atual:** Lista de botões idênticos (bug de highlight). + +**Novo:** +- Card com lista de abas scrollável +- Aba selecionada: background `PRIMARY` (20% alpha), texto `PRIMARY`, borda `PRIMARY` +- Aba não selecionada: background `SURFACE_2`, texto `TEXT` +- Preview abaixo da lista em card separado + +### 5.3 `screens/configuracao_colunas.rs` + +**Atual:** Coluna plana de inputs. + +**Novo:** +- Seção de configuração em card `SURFACE` +- Labels com `TEXT_SECONDARY`, inputs com fundo `BG`, borda `BORDER` +- Campos opcionais: checkbox com estilo consistente + input inline +- Erros em card com borda `ERROR` (20% alpha) +- Botões na barra inferior fixada: "Voltar" (ghost), "Analisar" (primary), "Reanalisar" (secondary), "Salvar" (ghost) + +### 5.4 `screens/resultado.rs` + +**Atual:** Coluna de texto puro. + +**Novo (alinhado ao mockup):** + +1. **Header row:** título + botões de ação à direita +2. **Stat cards row:** 3 cards — "Notas Faltantes" (azul), "Duplicadas" (vermelho), "Total R$" (texto branco) +3. **Card de completude por série:** para cada série, label + progress bar colorida por threshold +4. **Controle de itens/página:** botões com highlight no ativo +5. **Seções faltantes/duplicatas:** cabeçalho de série em row com badge de contagem + botão Copiar; itens em lista + +### 5.5 `screens/layouts.rs` + +**Atual:** Coluna plana. + +**Novo:** +- Seções CSV e XLSX em cards separados +- Cada layout numa row com hover highlight +- Botões de ação menores (ícone + texto compacto) +- Linha de importar JSON no rodapé do card + +### 5.6 `components/modal.rs` + +**Atual:** Box com estilo do tema padrão. + +**Novo:** +- Background `SURFACE`, borda `BORDER`, radius `LG` +- Título `TEXT` 18px, mensagem `TEXT_SECONDARY` 14px +- Separador entre conteúdo e botões +- Botão "Fechar" ghost, "Confirmar" primary +- Tipos Erro/Aviso com ícone + cor no título + +### 5.7 `components/tabela_preview.rs` + +**Atual:** Monospace puro. + +**Novo:** +- Cabeçalho com background `SURFACE_2`, texto `TEXT_SECONDARY` +- Células com background `SURFACE`, texto `TEXT`, fonte monospace +- Borda inferior `BORDER` nas células + +### 5.8 `components/paginacao.rs` + +**Atual:** Row simples de botões. + +**Novo:** +- Botões ◀/▶ com estilo ghost +- "Página X / Y" em `TEXT_SECONDARY` + +--- + +## 6. Arquivos a criar/modificar + +| Arquivo | Ação | +|---------|------| +| `src/main.rs` | Adicionar `.theme(...)` | +| `src/ui/mod.rs` | Adicionar `pub mod theme;` | +| `src/ui/theme.rs` | **Criar** — paleta + helpers de estilo | +| `src/ui/app.rs` | Breadcrumb novo estilo + tela Analisando centralizada | +| `src/ui/screens/import.rs` | Reescrever | +| `src/ui/screens/selecionar_aba.rs` | Reescrever (corrigir bug highlight) | +| `src/ui/screens/configuracao_colunas.rs` | Reescrever | +| `src/ui/screens/resultado.rs` | Reescrever | +| `src/ui/screens/layouts.rs` | Reescrever | +| `src/ui/components/modal.rs` | Reescrever | +| `src/ui/components/tabela_preview.rs` | Reescrever | +| `src/ui/components/paginacao.rs` | Reescrever | + +--- + +## 7. Limitações do iced 0.13 e workarounds + +| Limitação CSS | Workaround iced | +|---------------|----------------| +| `box-shadow` | Cor de borda ou sem sombra (aceitar diferença) | +| `rgba(r,g,b,0.2)` | `Color { r, g, b, a: 0.2 }` com valores 0.0–1.0 | +| Font Inter | Usa fonte do sistema (system-ui) — sem mudança necessária | +| `display: flex; gap` | `row![...].spacing(N)` | +| `border-bottom` nas células | `container` com border bottom via `border.width` fracional não suportado — usar separador visual alternativo | + +--- + +## 8. Ordem de implementação + +1. `theme.rs` — paleta e helpers (base para tudo) +2. `main.rs` — ativar tema +3. `modal.rs` — usado por todas as telas +4. `resultado.rs` — tela principal do mockup +5. `import.rs` +6. `selecionar_aba.rs` +7. `configuracao_colunas.rs` +8. `layouts.rs` +9. `app.rs` — breadcrumb + Analisando +10. `tabela_preview.rs` + `paginacao.rs` +11. Compilar e corrigir + +--- + +## 9. Notas de compatibilidade iced 0.13 + +- `button::Style` inclui `background`, `text_color`, `border: Border { color, width, radius }`, `shadow` +- `container::Style` inclui `background`, `text_color`, `border`, `shadow` +- `progress_bar::Style` inclui `background` e `bar` +- `text_input::Style` inclui `background`, `border`, `icon`, `placeholder`, `value`, `selection` +- Closures de estilo recebem `&Theme` e retornam o `Style` concreto do widget +- `iced::Border` aceita `radius: iced::border::Radius` — usar `N.into()` para uniform radius diff --git a/src/main.rs b/src/main.rs index 3a9bc66..3818854 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use ui::app::App; fn main() -> iced::Result { iced::application("Comparador de Notas", App::update, App::view) + .theme(|_app| ui::theme::tema_dark()) .window(iced::window::Settings { size: iced::Size::new(1024.0, 768.0), min_size: Some(iced::Size::new(800.0, 600.0)), diff --git a/src/ui/app.rs b/src/ui/app.rs index b112794..b914a57 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -13,6 +13,7 @@ use crate::domain::{ }; use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations}; use crate::ui::message::{Message, ResultadoPendente}; +use crate::ui::theme as t; use iced::widget::{column, container, text, Row}; use iced::{Alignment, Element, Length, Task}; use rusqlite::Connection; @@ -680,10 +681,13 @@ impl App { EstadoApp::Analisando => { container( column![ - text("Analisando... aguarde.").size(22), + text("Analisando...").size(20).color(t::TEXT_SECONDARY), + text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED), ] + .spacing(8) .align_x(Alignment::Center), ) + .style(t::fundo) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) @@ -701,7 +705,11 @@ impl App { .height(Length::Fill) .into() } else { - conteudo + container(conteudo) + .style(t::fundo) + .width(Length::Fill) + .height(Length::Fill) + .into() }; // Modal por cima de tudo @@ -722,31 +730,35 @@ impl App { _ => 1, }; - let passos = [("1. Arquivo", 1), ("2. Colunas", 2), ("3. Resultado", 3)]; + let passos = [("1. Arquivo", 1usize), ("2. Colunas", 2), ("3. Resultado", 3)]; let mut items: Vec> = Vec::new(); for (i, (label, n)) in passos.iter().enumerate() { - let t = if *n == passo_ativo { - // Active step: bold-weight via larger size + primary color - text(*label).size(15).color(iced::Color::from_rgb(0.15, 0.5, 0.9)) + let elem: Element<'_, Message> = if *n == passo_ativo { + container(text(*label).size(13).color(t::PRIMARY)) + .padding([4, 10]) + .style(|_theme| iced::widget::container::Style { + background: Some(iced::Color { a: 0.15, ..t::PRIMARY }.into()), + border: iced::Border { color: iced::Color { a: 0.3, ..t::PRIMARY }, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .into() } else if *n < passo_ativo { - // Completed step: muted color - text(*label).size(14).color(iced::Color::from_rgb(0.5, 0.5, 0.5)) + text(*label).size(13).color(t::TEXT_SECONDARY).into() } else { - // Future step: even more muted - text(*label).size(14).color(iced::Color::from_rgb(0.6, 0.6, 0.6)) + text(*label).size(13).color(t::TEXT_MUTED).into() }; - items.push(t.into()); + items.push(elem); if i < passos.len() - 1 { - items.push(text(" › ").size(14).color(iced::Color::from_rgb(0.5, 0.5, 0.5)).into()); + items.push(text(" › ").size(13).color(t::TEXT_MUTED).into()); } } container( - Row::with_children(items) - .align_y(Alignment::Center), + Row::with_children(items).align_y(Alignment::Center), ) - .padding([4, 8]) + .style(t::breadcrumb_bg) + .padding([10, 20]) .width(Length::Fill) .into() } diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index 3e78bfb..8da49c9 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -1,5 +1,6 @@ use crate::ui::app::EstadoModal; use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{button, column, container, mouse_area, row, stack, text, text_input}; use iced::{Alignment, Color, Element, Length}; @@ -14,7 +15,7 @@ pub fn view_com_modal<'a>( .width(Length::Fill) .height(Length::Fill) .style(|_theme| container::Style { - background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.5).into()), + background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()), ..Default::default() }) .center_x(Length::Fill) @@ -27,59 +28,118 @@ pub fn view_com_modal<'a>( fn view_modal(modal: &EstadoModal) -> Element<'_, Message> { match modal { - EstadoModal::Informacao { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false), - EstadoModal::Aviso { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false), - EstadoModal::Erro { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false), + EstadoModal::Informacao { titulo, mensagem } => { + caixa_modal(titulo, mensagem, TipoModal::Info, None, false) + } + EstadoModal::Aviso { titulo, mensagem } => { + caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false) + } + EstadoModal::Erro { titulo, mensagem } => { + caixa_modal(titulo, mensagem, TipoModal::Erro, None, false) + } EstadoModal::Confirmacao { titulo, mensagem, .. - } => caixa_modal(titulo, mensagem, None, true), + } => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true), EstadoModal::InputTexto { titulo, mensagem, texto, .. - } => caixa_modal(titulo, mensagem, Some(texto.as_str()), true), + } => caixa_modal( + titulo, + mensagem, + TipoModal::Info, + Some(texto.as_str()), + true, + ), } } +enum TipoModal { + Info, + Aviso, + Erro, + Confirmacao, +} + fn caixa_modal<'a>( titulo: &'a str, mensagem: &'a str, + tipo: TipoModal, input: Option<&'a str>, com_confirmar: bool, ) -> Element<'a, Message> { - let mut col = column![text(titulo).size(18), text(mensagem).size(14),].spacing(8); + // Cor e label de ícone por tipo + let (cor_titulo, icone) = match tipo { + TipoModal::Info => (t::PRIMARY, "i"), + TipoModal::Aviso => (t::WARNING, "!"), + TipoModal::Erro => (t::DANGER, "x"), + TipoModal::Confirmacao => (t::WARNING, "?"), + }; + + let badge_icone = container(text(icone).size(13).color(cor_titulo)) + .padding([2, 8]) + .style(move |_theme| iced::widget::container::Style { + background: Some( + Color { + a: 0.15, + ..cor_titulo + } + .into(), + ), + border: iced::Border { + color: Color { + a: 0.3, + ..cor_titulo + }, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }); + + let titulo_row = row![badge_icone, text(titulo).size(17).color(t::TEXT),] + .spacing(8) + .align_y(Alignment::Center); + + let mut col = + column![titulo_row, text(mensagem).size(14).color(t::TEXT_SECONDARY),].spacing(12); if let Some(valor) = input { col = col.push( text_input("Nome...", valor) .on_input(Message::ModalTextoAlterado) - .padding(6), + .padding(8) + .style(t::input_dark), ); } - let mut botoes = row![button("Fechar").on_press(Message::ModalCancelado),].spacing(8); + // Separador + col = col.push( + container(iced::widget::horizontal_rule(1)) + .width(Length::Fill) + .padding([4, 0]), + ); + + // Botões + let btn_fechar = button("Fechar") + .on_press(Message::ModalCancelado) + .style(t::btn_ghost); + + let mut botoes = row![btn_fechar].spacing(8); if com_confirmar { - botoes = botoes.push(button("Confirmar").on_press(Message::ModalConfirmado)); + let btn_confirmar = button("Confirmar") + .on_press(Message::ModalConfirmado) + .style(t::btn_primary); + botoes = botoes.push(btn_confirmar); } col = col.push(botoes); container(col.align_x(Alignment::Start)) - .width(Length::Fixed(400.0)) + .width(Length::Fixed(420.0)) .padding(24) - .style(|theme: &iced::Theme| { - let palette = theme.extended_palette(); - container::Style { - background: Some(palette.background.base.color.into()), - border: iced::Border { - color: palette.background.strong.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() - } - }) + .style(t::card) .into() } diff --git a/src/ui/components/paginacao.rs b/src/ui/components/paginacao.rs index 6b8bc09..8657c25 100644 --- a/src/ui/components/paginacao.rs +++ b/src/ui/components/paginacao.rs @@ -1,6 +1,7 @@ use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{button, row, text}; -use iced::Element; +use iced::{Alignment, Element}; /// Renderiza controles de paginação reutilizáveis. pub fn controles_paginacao( @@ -9,15 +10,22 @@ pub fn controles_paginacao( msg_anterior: Message, msg_proximo: Message, ) -> Element<'static, Message> { - let btn_anterior = button("◀").on_press_maybe((pagina_atual > 0).then_some(msg_anterior)); - let btn_proximo = - button("▶").on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo)); + let btn_anterior = button("◀") + .on_press_maybe((pagina_atual > 0).then_some(msg_anterior)) + .style(t::btn_ghost); + + let btn_proximo = button("▶") + .on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo)) + .style(t::btn_ghost); row![ btn_anterior, - text(format!("Página {} / {}", pagina_atual + 1, total_paginas)).size(13), + text(format!("Página {} / {}", pagina_atual + 1, total_paginas)) + .size(13) + .color(t::TEXT_SECONDARY), btn_proximo, ] .spacing(8) + .align_y(Alignment::Center) .into() } diff --git a/src/ui/components/tabela_preview.rs b/src/ui/components/tabela_preview.rs index 2f6600f..38d2528 100644 --- a/src/ui/components/tabela_preview.rs +++ b/src/ui/components/tabela_preview.rs @@ -1,44 +1,80 @@ use crate::ui::message::Message; use crate::ui::screens::indice_para_letra; -use iced::widget::{column, row, scrollable, text}; -use iced::{Element, Font, Length}; +use crate::ui::theme as t; +use iced::widget::{column, container, row, scrollable, text}; +use iced::{Border, Color, Element, Font, Length}; /// Renderiza uma tabela de pré-visualização das primeiras linhas do arquivo. /// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados. pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0); if num_colunas == 0 { - return text("(vazio)").size(12).into(); + return text("(vazio)").size(12).color(t::TEXT_MUTED).into(); } - let cabecalho = row((0..num_colunas) - .map(|i| { - text(format!("{} ({})", indice_para_letra(i), i)) - .font(Font::MONOSPACE) - .size(12) - .width(Length::Fixed(120.0)) - .into() - }) - .collect::>()) - .spacing(4); - - let linhas_view = linhas.iter().map(|linha| { + // Cabeçalho estilo Excel — fundo SURFACE_2 + let cabecalho = container( row((0..num_colunas) - .map(|col| { - let celula = linha.get(col).map(|s| s.as_str()).unwrap_or(""); - let truncado: String = if celula.chars().count() > 30 { - format!("{}...", celula.chars().take(30).collect::()) - } else { - celula.to_string() - }; - text(truncado) - .font(Font::MONOSPACE) - .size(11) - .width(Length::Fixed(120.0)) - .into() + .map(|i| { + container( + text(format!("{} ({})", indice_para_letra(i), i)) + .font(Font::MONOSPACE) + .size(12) + .color(t::TEXT_SECONDARY), + ) + .width(Length::Fixed(120.0)) + .padding([4, 6]) + .into() }) .collect::>()) - .spacing(4) + .spacing(0), + ) + .width(Length::Shrink) + .style(|_theme| iced::widget::container::Style { + background: Some(t::SURFACE_2.into()), + border: Border { + color: t::BORDER, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + + let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| { + // Linhas alternadas: SURFACE e SURFACE_2 levemente + let bg = if idx % 2 == 0 { + t::SURFACE + } else { + Color { + r: t::SURFACE.r + 0.01, + g: t::SURFACE.g + 0.01, + b: t::SURFACE.b + 0.015, + a: 1.0, + } + }; + + container( + row((0..num_colunas) + .map(|col| { + let celula = linha.get(col).map(|s| s.as_str()).unwrap_or(""); + let truncado: String = if celula.chars().count() > 30 { + format!("{}...", celula.chars().take(30).collect::()) + } else { + celula.to_string() + }; + container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT)) + .width(Length::Fixed(120.0)) + .padding([3, 6]) + .into() + }) + .collect::>()) + .spacing(0), + ) + .width(Length::Shrink) + .style(move |_theme| iced::widget::container::Style { + background: Some(bg.into()), + ..Default::default() + }) .into() }); @@ -47,12 +83,23 @@ pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { .chain(linhas_view) .collect::>(), ) - .spacing(2); + .spacing(0); - scrollable(todas_linhas) - .direction(scrollable::Direction::Horizontal( - scrollable::Scrollbar::default(), - )) - .height(Length::Fixed(160.0)) - .into() + container( + scrollable(todas_linhas) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default(), + )) + .height(Length::Fixed(160.0)), + ) + .style(|_theme| iced::widget::container::Style { + background: Some(t::SURFACE.into()), + border: Border { + color: t::BORDER, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }) + .into() } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 54e2e99..89cae07 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,3 +2,4 @@ pub mod app; pub mod components; pub mod message; pub mod screens; +pub mod theme; diff --git a/src/ui/screens/configuracao_colunas.rs b/src/ui/screens/configuracao_colunas.rs index 2adc136..9407b05 100644 --- a/src/ui/screens/configuracao_colunas.rs +++ b/src/ui/screens/configuracao_colunas.rs @@ -1,6 +1,7 @@ use crate::domain::entities::layout::TipoArquivo; use crate::ui::app::App; use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column, }; @@ -16,15 +17,30 @@ const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"]; /// Tela de configuração de colunas. pub fn view(app: &App) -> Element<'_, Message> { - let titulo = text("Configuração de Colunas").size(22); + // ── Cabeçalho ───────────────────────────────────────────────────────────── + let header = column![ + text("Configuração de Colunas").size(20).color(t::TEXT), + if let Some(caminho) = &app.caminho_arquivo { + row![ + text("Arquivo:").size(12).color(t::TEXT_MUTED), + text( + caminho + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + ) + .size(12) + .color(t::TEXT_SECONDARY), + ] + .spacing(4) + .align_y(Alignment::Center) + } else { + row![text("").size(12)] + }, + ] + .spacing(4); - let arquivo_label = if let Some(caminho) = &app.caminho_arquivo { - text(format!("Arquivo: {}", caminho.display())).size(13) - } else { - text("").size(13) - }; - - // Seletor de layout + // ── Seletor de layout ────────────────────────────────────────────────────── let tipo_atual = app.tipo_arquivo_atual.clone(); let opcoes_layout: Vec = app .layouts_salvos @@ -38,95 +54,169 @@ pub fn view(app: &App) -> Element<'_, Message> { Some(app.nome_layout_atual.clone()) }; - let secao_layout = row![ - text("Layout:").size(14), - pick_list(opcoes_layout, nome_layout_sel, { - let layouts = app.layouts_salvos.clone(); - move |nome_selecionado: String| { - // Busca o id do layout pelo nome e dispara LayoutSelecionado - if let Some(id) = layouts - .iter() - .find(|l| l.nome() == nome_selecionado) - .and_then(|l| l.id()) - { - Message::LayoutSelecionado(id) - } else { - Message::NomeLayoutAlterado(nome_selecionado) + let secao_layout = container( + row![ + text("Layout:") + .size(13) + .color(t::TEXT_SECONDARY) + .width(Length::Fixed(80.0)), + pick_list(opcoes_layout, nome_layout_sel, { + let layouts = app.layouts_salvos.clone(); + move |nome_selecionado: String| { + if let Some(id) = layouts + .iter() + .find(|l| l.nome() == nome_selecionado) + .and_then(|l| l.id()) + { + Message::LayoutSelecionado(id) + } else { + Message::NomeLayoutAlterado(nome_selecionado) + } } - } - }) - .placeholder("— Selecionar layout —") - .width(Length::Fixed(240.0)), - ] - .spacing(8) - .align_y(Alignment::Center); + }) + .placeholder("— Selecionar layout —") + .style(input_style_pick) + .width(Length::Fixed(260.0)), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .style(t::card_secondary) + .padding([10, 14]) + .width(Length::Fill); - // Configuração específica por tipo + // ── Configuração específica ─────────────────────────────────────────────── let config_section = match app.tipo_arquivo_atual { TipoArquivo::Csv => view_csv(app), TipoArquivo::Xlsx => view_xlsx(app), }; - // Pré-visualização + // ── Pré-visualização ────────────────────────────────────────────────────── let preview_section: Element = if let Some(linhas) = &app.preview_arquivo { - column![ - text("Pré-visualização:").size(13), - crate::ui::components::tabela_preview::tabela_preview(linhas), - ] - .spacing(4) + container( + column![ + text("Pré-visualização (5 primeiras linhas)") + .size(13) + .color(t::TEXT_SECONDARY), + crate::ui::components::tabela_preview::tabela_preview(linhas), + ] + .spacing(8), + ) + .style(t::card) + .padding(14) + .width(Length::Fill) .into() } else { - text("").into() + text("").size(1).into() }; - // Validação + // ── Validação ───────────────────────────────────────────────────────────── let (valido, erros) = validar_config(app); let erros_section: Element = if erros.is_empty() { - text("").into() + text("").size(1).into() } else { - Column::with_children( - erros - .iter() - .map(|e| text(format!("⚠ {}", e)).size(13).into()) - .collect::>(), + container( + Column::with_children( + erros + .iter() + .map(|e| text(format!("⚠ {}", e)).size(13).color(t::DANGER).into()) + .collect::>(), + ) + .spacing(4), ) - .spacing(2) + .style(t::area_erro) + .padding([10, 14]) + .width(Length::Fill) .into() }; + // ── Botões ──────────────────────────────────────────────────────────────── let tem_arquivo = app.caminho_arquivo.is_some(); let tem_notas = !app.notas_importadas.is_empty(); - let botoes = row![ - button("< Voltar").on_press(Message::Voltar), - button("▶ Importar e Analisar") - .on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao)), - button("🔄 Reanalisar") - .on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo)), - button("💾 Salvar como layout...").on_press(Message::SalvarLayout), - ] - .spacing(8); + let botoes = container( + row![ + button(text("◀ Voltar").size(13)) + .on_press(Message::Voltar) + .style(t::btn_ghost) + .padding([9, 14]), + button(text("▶ Importar e Analisar").size(13)) + .on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao)) + .style(t::btn_primary) + .padding([9, 14]), + button(text("🔄 Reanalisar").size(13)) + .on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo)) + .style(t::btn_secondary) + .padding([9, 14]), + button(text("💾 Salvar layout").size(13)) + .on_press(Message::SalvarLayout) + .style(t::btn_ghost) + .padding([9, 14]), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .style(t::card_secondary) + .padding([12, 16]) + .width(Length::Fill); + // ── Layout geral ────────────────────────────────────────────────────────── let content = column![ - titulo, - arquivo_label, + header, secao_layout, config_section, preview_section, erros_section, botoes, ] - .spacing(12) - .padding(16) + .spacing(14) + .padding([20, 24]) .width(Length::Fill); container(scrollable(content)) + .style(t::fundo) .width(Length::Fill) .height(Length::Fill) .into() } +// ─── Estilo do pick_list ────────────────────────────────────────────────────── + +fn input_style_pick( + theme: &iced::Theme, + status: iced::widget::pick_list::Status, +) -> iced::widget::pick_list::Style { + let base = iced::widget::pick_list::Style { + text_color: t::TEXT, + placeholder_color: t::TEXT_MUTED, + handle_color: t::TEXT_SECONDARY, + background: t::BG.into(), + border: iced::Border { + color: t::BORDER, + width: 1.0, + radius: 6.0.into(), + }, + }; + match status { + iced::widget::pick_list::Status::Opened | iced::widget::pick_list::Status::Hovered => { + iced::widget::pick_list::Style { + border: iced::Border { + color: t::PRIMARY, + ..base.border + }, + ..base + } + } + _ => { + let _ = theme; + base + } + } +} + +// ─── Configuração CSV ──────────────────────────────────────────────────────── + fn view_csv(app: &App) -> Element<'_, Message> { let c = &app.layout_csv_atual; @@ -142,17 +232,16 @@ fn view_csv(app: &App) -> Element<'_, Message> { .iter() .map(|(s, _)| s.to_string()) .collect(); - let opcoes_enc: Vec = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect(); let linha_cabecalho_str = c.linha_cabecalho.to_string(); let indice_numero_str = c.indice_numero.to_string(); let indice_serie_str = c.indice_serie.to_string(); - column![ - text("Configurações CSV").size(16), - row![ - text("Delimitador:").size(14).width(Length::Fixed(220.0)), + let inner = column![ + secao_titulo("Importação CSV"), + campo_row( + "Delimitador", pick_list(opcoes_delim, Some(delim_str), |selecionado| { let c = OPCOES_DELIMITADOR .iter() @@ -161,84 +250,177 @@ fn view_csv(app: &App) -> Element<'_, Message> { .unwrap_or(','); Message::DelimitadorAlterado(c) }) - .width(Length::Fixed(200.0)), - ] - .spacing(8) - .align_y(Alignment::Center), - row![ - text("Encoding:").size(14).width(Length::Fixed(220.0)), + .style(input_style_pick) + .width(Length::Fixed(200.0)) + .into(), + ), + campo_row( + "Encoding", pick_list( opcoes_enc, Some(c.encoding.clone()), Message::EncodingAlterado ) - .width(Length::Fixed(200.0)), - ] - .spacing(8) - .align_y(Alignment::Center), - row![ - text("Linha do cabeçalho:") - .size(14) - .width(Length::Fixed(220.0)), + .style(input_style_pick) + .width(Length::Fixed(200.0)) + .into(), + ), + campo_row( + "Linha cabeçalho", text_input("0", &linha_cabecalho_str) .on_input(|s| { s.parse::() .map(Message::LinhaCabecalhoAlterada) .unwrap_or(Message::Noop) }) - .width(Length::Fixed(80.0)), - ] - .spacing(8) - .align_y(Alignment::Center), - text("Mapeamento de Colunas (índice base 0)").size(14), - row![ - text("Número (obrigatório):") - .size(14) - .width(Length::Fixed(220.0)), + .style(t::input_dark) + .width(Length::Fixed(80.0)) + .into(), + ), + secao_subtitulo("Mapeamento de colunas (índice base 0)"), + campo_row( + "Número (obrigatório)", text_input("0", &indice_numero_str) .on_input(|s| { s.parse::() .map(Message::IndiceNumeroAlterado) .unwrap_or(Message::Noop) }) - .width(Length::Fixed(80.0)), - ] - .spacing(8) - .align_y(Alignment::Center), - row![ - text("Série (obrigatório):") - .size(14) - .width(Length::Fixed(220.0)), + .style(t::input_dark) + .width(Length::Fixed(80.0)) + .into(), + ), + campo_row( + "Série (obrigatório)", text_input("0", &indice_serie_str) .on_input(|s| { s.parse::() .map(Message::IndiceSerieAlterado) .unwrap_or(Message::Noop) }) - .width(Length::Fixed(80.0)), - ] - .spacing(8) - .align_y(Alignment::Center), + .style(t::input_dark) + .width(Length::Fixed(80.0)) + .into(), + ), campo_indice_opcional_csv( - "Valor (opcional):", + "Valor (opcional)", c.indice_valor, Message::IndiceValorToggle, Message::IndiceValorAlterado, ), campo_indice_opcional_csv( - "Data (opcional):", + "Data (opcional)", c.indice_data, Message::IndiceDataToggle, Message::IndiceDataAlterado, ), campo_indice_opcional_csv( - "Tipo Documento (opcional):", + "Tipo Documento (opcional)", c.indice_documento_tipo, Message::IndiceDocTipoToggle, Message::IndiceDocTipoAlterado, ), ] - .spacing(8) + .spacing(10); + + container(inner) + .style(t::card) + .padding([14, 18]) + .width(Length::Fill) + .into() +} + +// ─── Configuração XLSX ──────────────────────────────────────────────────────── + +fn view_xlsx(app: &App) -> Element<'_, Message> { + let c = &app.layout_xlsx_atual; + + let secao_aba: Element = if app.abas_xlsx.is_empty() { + campo_row( + "Aba", + text_input("Nome da aba", &c.aba) + .on_input(Message::AbaXlsxAlterada) + .style(t::input_dark) + .width(Length::Fixed(200.0)) + .into(), + ) + } else { + let aba_sel = if c.aba.is_empty() { + None + } else { + Some(c.aba.clone()) + }; + campo_row( + "Aba", + pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada) + .style(input_style_pick) + .width(Length::Fixed(200.0)) + .into(), + ) + }; + + let inner = column![ + secao_titulo("Importação XLSX"), + secao_aba, + secao_subtitulo("Mapeamento de colunas (formato LetraLinha, ex: B3)"), + campo_letra_linha( + "Número (obrigatório)", + &c.pos_numero, + Message::PosNumeroAlterada + ), + campo_letra_linha( + "Série (obrigatório)", + &c.pos_serie, + Message::PosSerieAlterada + ), + campo_letra_linha_opcional( + "Valor (opcional)", + c.pos_valor.as_deref(), + Message::PosValorToggle, + Message::PosValorAlterada, + ), + campo_letra_linha_opcional( + "Data (opcional)", + c.pos_data.as_deref(), + Message::PosDataToggle, + Message::PosDataAlterada, + ), + campo_letra_linha_opcional( + "Tipo Documento (opcional)", + c.pos_documento_tipo.as_deref(), + Message::PosDocTipoToggle, + Message::PosDocTipoAlterada, + ), + ] + .spacing(10); + + container(inner) + .style(t::card) + .padding([14, 18]) + .width(Length::Fill) + .into() +} + +// ─── Helpers visuais ───────────────────────────────────────────────────────── + +fn secao_titulo(label: &str) -> Element<'_, Message> { + text(label).size(14).color(t::TEXT).into() +} + +fn secao_subtitulo(label: &str) -> Element<'_, Message> { + text(label).size(12).color(t::TEXT_SECONDARY).into() +} + +fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> { + row![ + text(label) + .size(13) + .color(t::TEXT_SECONDARY) + .width(Length::Fixed(220.0)), + input, + ] + .spacing(10) + .align_y(Alignment::Center) .into() } @@ -251,16 +433,17 @@ fn campo_indice_opcional_csv( let ativo = valor.is_some(); let val_str = valor.map(|v| v.to_string()).unwrap_or_default(); - let cb = checkbox(label, ativo).on_toggle(msg_toggle); + let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13); if ativo { row![ cb.width(Length::Fixed(220.0)), text_input("0", &val_str) .on_input(move |s| { s.parse::().map(&msg_valor).unwrap_or(Message::Noop) }) + .style(t::input_dark) .width(Length::Fixed(80.0)), ] - .spacing(8) + .spacing(10) .align_y(Alignment::Center) .into() } else { @@ -268,86 +451,19 @@ fn campo_indice_opcional_csv( } } -fn view_xlsx(app: &App) -> Element<'_, Message> { - let c = &app.layout_xlsx_atual; - - let secao_aba: Element = if app.abas_xlsx.is_empty() { - row![ - text("Aba:").size(14).width(Length::Fixed(200.0)), - text_input("Nome da aba", &c.aba) - .on_input(Message::AbaXlsxAlterada) - .width(Length::Fixed(200.0)), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() - } else { - let aba_sel = if c.aba.is_empty() { - None - } else { - Some(c.aba.clone()) - }; - row![ - text("Aba:").size(14).width(Length::Fixed(200.0)), - pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada) - .width(Length::Fixed(200.0)), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() - }; - - column![ - text("Configurações XLSX").size(16), - secao_aba, - text("Mapeamento de Colunas (formato LetraLinha, ex: B3)").size(14), - campo_letra_linha( - "Número (obrigatório):", - &c.pos_numero, - Message::PosNumeroAlterada - ), - campo_letra_linha( - "Série (obrigatório):", - &c.pos_serie, - Message::PosSerieAlterada - ), - campo_letra_linha_opcional( - "Valor (opcional):", - c.pos_valor.as_deref(), - Message::PosValorToggle, - Message::PosValorAlterada, - ), - campo_letra_linha_opcional( - "Data (opcional):", - c.pos_data.as_deref(), - Message::PosDataToggle, - Message::PosDataAlterada, - ), - campo_letra_linha_opcional( - "Tipo Documento (opcional):", - c.pos_documento_tipo.as_deref(), - Message::PosDocTipoToggle, - Message::PosDocTipoAlterada, - ), - ] - .spacing(8) - .into() -} - fn campo_letra_linha<'a>( label: &'a str, valor: &'a str, msg: impl Fn(String) -> Message + 'a, ) -> Element<'a, Message> { - row![ - text(label).size(14).width(Length::Fixed(200.0)), + campo_row( + label, text_input("ex: B3", valor) .on_input(msg) - .width(Length::Fixed(100.0)), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() + .style(t::input_dark) + .width(Length::Fixed(100.0)) + .into(), + ) } fn campo_letra_linha_opcional<'a>( @@ -359,16 +475,17 @@ fn campo_letra_linha_opcional<'a>( let ativo = valor.is_some(); let val_str = valor.unwrap_or("").to_string(); - let cb = checkbox(label, ativo).on_toggle(msg_toggle); + let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13); if ativo { row![ - cb.width(Length::Fixed(200.0)), + cb.width(Length::Fixed(220.0)), text_input("ex: B3", &val_str) .on_input(msg_valor) + .style(t::input_dark) .width(Length::Fixed(100.0)), ] - .spacing(8) + .spacing(10) .align_y(Alignment::Center) .into() } else { @@ -376,7 +493,8 @@ fn campo_letra_linha_opcional<'a>( } } -/// Valida a configuração atual. Retorna (é_válido, lista_de_erros). +// ─── Validação ──────────────────────────────────────────────────────────────── + fn validar_config(app: &App) -> (bool, Vec) { let mut erros = Vec::new(); diff --git a/src/ui/screens/import.rs b/src/ui/screens/import.rs index 301cfc3..a343f6a 100644 --- a/src/ui/screens/import.rs +++ b/src/ui/screens/import.rs @@ -1,27 +1,97 @@ use crate::ui::app::App; use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{button, column, container, pick_list, row, text}; use iced::{Alignment, Element, Length}; /// Tela de importação de arquivos. pub fn view(app: &App) -> Element<'_, Message> { - let titulo = text("Comparador de Notas — Importar Arquivo").size(22); - - // Nome do arquivo selecionado - let nome = if app.nome_arquivo.is_empty() { - "Nenhum arquivo selecionado".to_string() + // ── Área de seleção de arquivo ──────────────────────────────────────────── + let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() { + ("Nenhum arquivo selecionado".to_string(), false) } else { - app.nome_arquivo.clone() + (app.nome_arquivo.clone(), true) }; - let secao_arquivo = row![ - text(nome).size(14).width(Length::Fill), - button("Selecionar arquivo...").on_press(Message::SelecionarArquivo), - ] - .spacing(8) - .align_y(Alignment::Center); + let icone_arquivo: Element = text(if tem_arquivo { "📄" } else { "📂" }) + .size(32) + .color(if tem_arquivo { + t::PRIMARY + } else { + t::TEXT_MUTED + }) + .into(); - // Seletor de layout compatível com o tipo atual + let texto_arquivo: Element = if tem_arquivo { + let nome_str = nome_arquivo.clone(); + column![ + text(nome_str).size(14).color(t::TEXT), + text("Arquivo pronto para configuração") + .size(12) + .color(t::TEXT_SECONDARY), + ] + .spacing(4) + .align_x(Alignment::Center) + .into() + } else { + column![ + text("Selecione um arquivo CSV ou XLSX") + .size(14) + .color(t::TEXT_SECONDARY), + text("Suportado: .csv, .xlsx, .xls") + .size(12) + .color(t::TEXT_MUTED), + ] + .spacing(4) + .align_x(Alignment::Center) + .into() + }; + + let drop_zone = container( + column![icone_arquivo, texto_arquivo] + .spacing(12) + .align_x(Alignment::Center) + .width(Length::Fill), + ) + .style(move |_theme| iced::widget::container::Style { + background: Some( + iced::Color { + a: 0.05, + ..t::PRIMARY + } + .into(), + ), + border: iced::Border { + color: if tem_arquivo { + iced::Color { + a: 0.5, + ..t::PRIMARY + } + } else { + iced::Color { + a: 0.3, + ..t::BORDER + } + }, + width: 1.5, + radius: 8.0.into(), + }, + ..Default::default() + }) + .padding([28, 20]) + .width(Length::Fill); + + let btn_selecionar = button( + row![text("Selecionar arquivo...").size(14)] + .align_y(Alignment::Center) + .spacing(6), + ) + .on_press(Message::SelecionarArquivo) + .style(t::btn_primary) + .width(Length::Fill) + .padding([10, 16]); + + // ── Seção de layout ─────────────────────────────────────────────────────── let tipo_atual = app.tipo_arquivo_atual.clone(); let opcoes_layout: Vec = app .layouts_salvos @@ -37,7 +107,10 @@ pub fn view(app: &App) -> Element<'_, Message> { }; let secao_layout = row![ - text("Layout:").size(14), + text("Layout salvo:") + .size(13) + .color(t::TEXT_SECONDARY) + .width(Length::Fixed(110.0)), pick_list(opcoes_layout, nome_layout_sel, { let layouts = app.layouts_salvos.clone(); move |nome_selecionado: String| { @@ -53,28 +126,69 @@ pub fn view(app: &App) -> Element<'_, Message> { } }) .placeholder("— Selecionar layout —") - .width(Length::Fixed(260.0)), - button("⚙ Gerenciar Layouts").on_press(Message::IrParaLayouts), + .width(Length::Fill), + button(text("Gerenciar").size(13)) + .on_press(Message::IrParaLayouts) + .style(t::btn_ghost) + .padding([8, 12]), ] .spacing(8) .align_y(Alignment::Center); - // Botão de avançar só aparece quando um arquivo foi selecionado - let botao_avancar: Element = if app.caminho_arquivo.is_some() { - button("▶ Configurar Colunas") + // ── Botão avançar ───────────────────────────────────────────────────────── + let botao_avancar: Element = if tem_arquivo { + button(row![text("Configurar Colunas ▶").size(14)].align_y(Alignment::Center)) .on_press(Message::IrParaConfiguracaoColunas) + .style(t::btn_primary) + .width(Length::Fill) + .padding([11, 16]) .into() } else { - text("").into() + container(text("").size(1)) + .height(Length::Fixed(42.0)) + .into() }; - let content = column![titulo, secao_arquivo, secao_layout, botao_avancar] - .spacing(16) - .padding(24) + // ── Separador visual ────────────────────────────────────────────────────── + let separador = container(text("")) + .height(Length::Fixed(1.0)) + .width(Length::Fill) + .style(t::separador); + + // ── Card central ────────────────────────────────────────────────────────── + let card_inner = column![ + drop_zone, + btn_selecionar, + separador, + secao_layout, + botao_avancar, + ] + .spacing(14) + .padding(24) + .width(Length::Fill); + + let card = container(card_inner) + .style(t::card) + .max_width(560) .width(Length::Fill); - container(content) - .width(Length::Fill) - .height(Length::Fill) - .into() + // ── Layout geral ────────────────────────────────────────────────────────── + container( + column![ + text("Importar Arquivo").size(20).color(t::TEXT), + text("Selecione e configure sua planilha para análise") + .size(13) + .color(t::TEXT_SECONDARY), + card, + ] + .spacing(16) + .align_x(Alignment::Center) + .padding([32, 20]) + .width(Length::Fill), + ) + .style(t::fundo) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .into() } diff --git a/src/ui/screens/layouts.rs b/src/ui/screens/layouts.rs index 85582d0..b9e9e78 100644 --- a/src/ui/screens/layouts.rs +++ b/src/ui/screens/layouts.rs @@ -1,34 +1,39 @@ use crate::domain::entities::layout::{Layout, TipoArquivo}; use crate::ui::app::App; use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{button, column, container, horizontal_space, row, scrollable, text}; use iced::{Alignment, Element, Length}; /// Tela de gerenciamento de layouts. pub fn view(app: &App) -> Element<'_, Message> { - let titulo = text("Gerenciar Layouts").size(22); + let cabecalho = row![ + button("< Voltar") + .on_press(Message::IrParaImportacao) + .style(t::btn_secondary), + horizontal_space(), + button("Importar JSON") + .on_press(Message::ImportarLayoutJson) + .style(t::btn_ghost), + ] + .spacing(8) + .align_y(Alignment::Center) + .width(Length::Fill); - let botao_voltar = row![button("< Voltar").on_press(Message::IrParaImportacao)].spacing(8); + let titulo = text("Gerenciar Layouts").size(22).color(t::TEXT); let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv); let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx); - // Importar de JSON - let importar_row = row![ - text("Importar layout de arquivo JSON:").size(14), - button("📥 Importar JSON").on_press(Message::ImportarLayoutJson), - ] - .spacing(8) - .align_y(Alignment::Center); - - let content = column![titulo, botao_voltar, secao_csv, secao_xlsx, importar_row,] + let content = column![titulo, cabecalho, secao_csv, secao_xlsx,] .spacing(16) - .padding(16) + .padding(20) .width(Length::Fill); container(scrollable(content)) .width(Length::Fill) .height(Length::Fill) + .style(t::fundo) .into() } @@ -37,30 +42,56 @@ fn view_secao_layouts<'a>( layouts: &'a [Layout], tipo: TipoArquivo, ) -> Element<'a, Message> { - let mut col = column![text(titulo).size(16)].spacing(4); + let titulo_widget = text(titulo).size(16).color(t::TEXT_SECONDARY); let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect(); + let mut col = column![titulo_widget].spacing(4); + if filtrados.is_empty() { - col = col.push(text("(nenhum layout salvo)").size(13)); - return col.into(); + col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED)); + return container(col) + .padding([12, 16]) + .width(Length::Fill) + .style(t::card) + .into(); } for layout in filtrados { if let Some(id) = layout.id() { - let linha = row![ - text(layout.nome()).size(14).width(Length::Fill), - horizontal_space(), - button("📂 Carregar").on_press(Message::LayoutSelecionado(id)), - button("📤 Exportar JSON").on_press(Message::ExportarLayoutJson(id)), - button("🗑 Excluir").on_press(Message::ExcluirLayout(id)), - ] - .spacing(8) - .align_y(Alignment::Center); + let linha = container( + row![ + text(layout.nome()) + .size(14) + .color(t::TEXT) + .width(Length::Fill), + horizontal_space(), + button("Carregar") + .on_press(Message::LayoutSelecionado(id)) + .style(t::btn_primary), + button("Exportar JSON") + .on_press(Message::ExportarLayoutJson(id)) + .style(t::btn_ghost), + button("Excluir") + .on_press(Message::ExcluirLayout(id)) + .style(t::btn_danger), + ] + .spacing(8) + .align_y(Alignment::Center) + .padding([10, 0]), + ) + .width(Length::Fill); col = col.push(linha); + + // Separador entre linhas + col = col.push(container(iced::widget::horizontal_rule(1)).width(Length::Fill)); } } - col.into() + container(col) + .padding([12, 16]) + .width(Length::Fill) + .style(t::card) + .into() } diff --git a/src/ui/screens/resultado.rs b/src/ui/screens/resultado.rs index 33bd619..2953e0c 100644 --- a/src/ui/screens/resultado.rs +++ b/src/ui/screens/resultado.rs @@ -4,73 +4,135 @@ use crate::domain::{ }; use crate::ui::app::App; use crate::ui::message::Message; -use iced::widget::{button, column, container, row, scrollable, text}; +use crate::ui::theme as t; +use iced::widget::{button, column, container, progress_bar, row, scrollable, text}; use iced::{Alignment, Element, Length}; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; /// Tela de resultados da análise. pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> { - let titulo = text("Resultado da Análise").size(22); + // ── Stat cards no topo ──────────────────────────────────────────────────── + let total_faltantes = resultado.total_faltantes(); + let total_duplicatas = resultado.total_duplicatas(); + let total_notas: usize = resultado.total_por_serie.values().sum(); + let valor_total_str = format!("R$ {}", formatar_valor_br(&resultado.soma_total)); - // Botões de ação superiores + let stat_faltantes = stat_card_widget("Faltantes", total_faltantes.to_string(), t::WARNING); + let stat_duplicatas = stat_card_widget("Duplicatas", total_duplicatas.to_string(), t::DANGER); + let stat_total = stat_card_widget("Total de Notas", total_notas.to_string(), t::PRIMARY); + let stat_valor = stat_card_widget("Valor Total", valor_total_str, t::SUCCESS); + + let stat_row = row![stat_faltantes, stat_duplicatas, stat_total, stat_valor] + .spacing(12) + .width(Length::Fill); + + // ── Botões de ação ──────────────────────────────────────────────────────── let botoes_topo = row![ - button("< Nova Análise").on_press(Message::NovaAnalise), // abre modal de confirmação - button("⚙ Reconfigurar Colunas").on_press(Message::IrParaConfiguracaoColunas), - button("🔄 Reanalisar Arquivo").on_press_maybe( - app.caminho_arquivo - .as_ref() - .map(|_| Message::ReanalisarArquivo) - ), - button("📄 Exportar PDF").on_press(Message::ExportarPdf), + button("< Nova Análise") + .on_press(Message::NovaAnalise) + .style(t::btn_secondary), + button("Reconfigurar Colunas") + .on_press(Message::IrParaConfiguracaoColunas) + .style(t::btn_ghost), + button("Reanalisar Arquivo") + .on_press_maybe( + app.caminho_arquivo + .as_ref() + .map(|_| Message::ReanalisarArquivo) + ) + .style(t::btn_ghost), + button("Exportar PDF") + .on_press(Message::ExportarPdf) + .style(t::btn_primary), ] .spacing(8); - // Controle de itens por página + // ── Controle de itens por página ────────────────────────────────────────── let opcoes_por_pagina = row(OPCOES_PAGINA .iter() .map(|&n| { + let ativo = n == app.itens_por_pagina; button(text(n.to_string()).size(13)) .on_press(Message::ItensPorPaginaAlterado(n)) + .style(if ativo { + t::btn_pagina_ativo + } else { + t::btn_pagina_inativo + }) .into() }) .collect::>()) .spacing(4); - let controle_pagina = row![text("Itens por página:").size(13), opcoes_por_pagina,] - .spacing(8) - .align_y(Alignment::Center); + let controle_pagina = row![ + text("Itens por página:").size(13).color(t::TEXT_SECONDARY), + opcoes_por_pagina, + ] + .spacing(8) + .align_y(Alignment::Center); - // Seções principais + // ── Seções principais ───────────────────────────────────────────────────── let secao_faltantes = view_faltantes(app, resultado); let secao_duplicatas = view_duplicatas(app, resultado); let secao_totais = view_totais(resultado); let conteudo = column![ - titulo, + row![text("Resultado da Análise").size(22).color(t::TEXT),], botoes_topo, + stat_row, controle_pagina, secao_faltantes, secao_duplicatas, secao_totais, ] .spacing(16) - .padding(16) + .padding(20) .width(Length::Fill); container(scrollable(conteudo)) .width(Length::Fill) .height(Length::Fill) + .style(t::fundo) + .into() +} + +/// Cria um stat card com label, valor e cor de destaque. +fn stat_card_widget<'a>(label: &'a str, valor: String, cor: iced::Color) -> Element<'a, Message> { + let conteudo = column![ + text(label).size(12).color(t::TEXT_SECONDARY), + text(valor).size(24).color(cor), + ] + .spacing(4); + + container(conteudo) + .padding([14, 18]) + .width(Length::Fill) + .style(t::stat_card) .into() } fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> { let total = resultado.total_faltantes(); - let mut col = column![text(format!("Notas Faltantes ({} total)", total)).size(18),].spacing(8); + + let titulo_row = row![ + text("Notas Faltantes").size(18).color(t::TEXT), + container(text(format!(" {} ", total)).size(12).color(t::WARNING)) + .padding([2, 8]) + .style(t::badge_aviso), + ] + .spacing(8) + .align_y(Alignment::Center); + + let mut col = column![titulo_row].spacing(12); if total == 0 { - col = col.push(text("✔ Nenhuma nota faltante.").size(14)); - return col.into(); + col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS)); + return container(col) + .padding(16) + .width(Length::Fill) + .style(t::card) + .into(); } let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect(); @@ -84,39 +146,60 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); let total_esperado = total_notas + faltantes.len(); - let percentual = total_notas as f64 / total_esperado as f64 * 100.0; + let percentual = total_notas as f64 / total_esperado as f64; + let percentual_f32 = percentual as f32; + // Cabeçalho da série let cabecalho_serie = row![ - text(format!( - "Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):", - chave.label(), - faltantes.len(), - total_notas, - total_esperado, - percentual, - )) - .size(14) + column![ + text(format!("Série {}", chave.label())) + .size(14) + .color(t::TEXT), + text(format!( + "{} faltante(s) — {}/{} notas ({:.1}% completo)", + faltantes.len(), + total_notas, + total_esperado, + percentual * 100.0, + )) + .size(12) + .color(t::TEXT_SECONDARY), + ] + .spacing(2) .width(Length::Fill), - button("📋 Copiar").on_press(Message::CopiarFaltantes(chave.clone())), + button("Copiar") + .on_press(Message::CopiarFaltantes(chave.clone())) + .style(t::btn_ghost), ] .spacing(8) .align_y(Alignment::Center); - col = col.push(cabecalho_serie); + // Progress bar de completude + let barra = progress_bar(0.0..=1.0, percentual_f32) + .height(6) + .style(t::progress_bar_por_percentual(percentual_f32)); - // Paginação + col = col.push(cabecalho_serie); + col = col.push(barra); + + // Paginação e lista let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1)); let inicio = pagina * app.itens_por_pagina; let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); + let mut lista = column![].spacing(2); for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) { - if a == b { - col = col.push(text(format!(" • {}", a)).size(13)); + let txt = if a == b { + text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY) } else { - col = col.push(text(format!(" • {}–{} ({} notas)", a, b, b - a + 1)).size(13)); - } + text(format!(" {}–{} ({} notas)", a, b, b - a + 1)) + .size(13) + .color(t::TEXT_SECONDARY) + }; + lista = lista.push(txt); } + col = col.push(lista); if total_paginas > 1 { col = col.push(crate::ui::components::paginacao::controles_paginacao( @@ -126,19 +209,43 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< Message::PaginaFaltantesAlterada(pagina + 1), )); } + + // Separador + col = col.push( + container(iced::widget::horizontal_rule(1)) + .width(Length::Fill) + .padding([4, 0]), + ); } - col.into() + container(col) + .padding(16) + .width(Length::Fill) + .style(t::card) + .into() } fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> { let total = resultado.total_duplicatas(); - let mut col = - column![text(format!("Notas Duplicadas ({} grupo(s))", total)).size(18),].spacing(8); + + let titulo_row = row![ + text("Notas Duplicadas").size(18).color(t::TEXT), + container(text(format!(" {} ", total)).size(12).color(t::DANGER)) + .padding([2, 8]) + .style(t::badge_perigo), + ] + .spacing(8) + .align_y(Alignment::Center); + + let mut col = column![titulo_row].spacing(12); if total == 0 { - col = col.push(text("✔ Nenhuma nota duplicada.").size(14)); - return col.into(); + col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS)); + return container(col) + .padding(16) + .width(Length::Fill) + .style(t::card) + .into(); } let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect(); @@ -152,13 +259,16 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element let cabecalho_serie = row![ text(format!( - "Série {} — {} grupo(s) duplicado(s):", + "Série {} — {} grupo(s) duplicado(s)", chave.label(), duplicatas.len() )) .size(14) + .color(t::TEXT) .width(Length::Fill), - button("📋 Copiar").on_press(Message::CopiarDuplicatas(chave.clone())), + button("Copiar") + .on_press(Message::CopiarDuplicatas(chave.clone())) + .style(t::btn_ghost), ] .spacing(8) .align_y(Alignment::Center); @@ -170,17 +280,20 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element let inicio = pagina * app.itens_por_pagina; let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); + let mut lista = column![].spacing(2); for (numero, count) in &duplicatas[inicio..fim] { - col = col.push( + lista = lista.push( text(format!( - " • NF {} / Série {} — {} ocorrências", + " NF {} / Série {} — {} ocorrências", numero, chave.label(), count )) - .size(13), + .size(13) + .color(t::TEXT_SECONDARY), ); } + col = col.push(lista); if total_paginas > 1 { col = col.push(crate::ui::components::paginacao::controles_paginacao( @@ -190,21 +303,25 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element Message::PaginaDuplicatasAlterada(pagina + 1), )); } + + col = col.push( + container(iced::widget::horizontal_rule(1)) + .width(Length::Fill) + .padding([4, 0]), + ); } - col.into() + container(col) + .padding(16) + .width(Length::Fill) + .style(t::card) + .into() } fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> { - let mut col = column![text("Totais").size(18)].spacing(4); + let titulo = text("Totais por Série").size(18).color(t::TEXT); - col = col.push( - text(format!( - "Total Geral: R$ {}", - formatar_valor_br(&resultado.soma_total) - )) - .size(14), - ); + let mut col = column![titulo].spacing(8); let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect(); chaves.sort(); @@ -212,16 +329,48 @@ fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> { for chave in chaves { let soma = &resultado.soma_por_serie[chave]; let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); - col = col.push( - text(format!( - " Série {}: {} nota(s) — R$ {}", - chave.label(), - total_notas, - formatar_valor_br(soma) - )) - .size(13), - ); + + let linha = row![ + text(format!("Série {}", chave.label())) + .size(14) + .color(t::TEXT) + .width(Length::Fill), + text(format!("{} nota(s)", total_notas)) + .size(13) + .color(t::TEXT_SECONDARY), + text(format!("R$ {}", formatar_valor_br(soma))) + .size(13) + .color(t::SUCCESS), + ] + .spacing(12) + .align_y(Alignment::Center); + + col = col.push(linha); } - col.into() + // Total geral + col = col.push( + container(iced::widget::horizontal_rule(1)) + .width(Length::Fill) + .padding([4, 0]), + ); + col = col.push( + row![ + text("Total Geral") + .size(15) + .color(t::TEXT) + .width(Length::Fill), + text(format!("R$ {}", formatar_valor_br(&resultado.soma_total))) + .size(15) + .color(t::SUCCESS), + ] + .spacing(12) + .align_y(Alignment::Center), + ); + + container(col) + .padding(16) + .width(Length::Fill) + .style(t::card) + .into() } diff --git a/src/ui/screens/selecionar_aba.rs b/src/ui/screens/selecionar_aba.rs index 9e8a49e..70ca5fb 100644 --- a/src/ui/screens/selecionar_aba.rs +++ b/src/ui/screens/selecionar_aba.rs @@ -1,78 +1,155 @@ use crate::ui::app::App; use crate::ui::message::Message; +use crate::ui::theme as t; use iced::widget::{button, column, container, row, scrollable, text}; -use iced::{Element, Length}; +use iced::{Alignment, Element, Length}; use std::path::PathBuf; /// Tela de seleção de aba de arquivo XLSX. pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Element<'a, Message> { - let titulo = text("Selecionar Aba da Planilha").size(22); + let nome_arquivo = caminho + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| caminho.display().to_string()); - let arquivo_label = text(format!("Arquivo: {}", caminho.display())).size(13); + let nome_arquivo_owned = nome_arquivo.clone(); - // Lista de abas como botões seleccionáveis + // ── Cabeçalho ───────────────────────────────────────────────────────────── + let header = column![ + text("Selecionar Aba").size(20).color(t::TEXT), + row![ + text("Arquivo:").size(12).color(t::TEXT_MUTED), + text(nome_arquivo_owned).size(12).color(t::TEXT_SECONDARY), + ] + .spacing(4) + .align_y(Alignment::Center), + ] + .spacing(4); + + // ── Lista de abas ───────────────────────────────────────────────────────── let aba_atual = &app.layout_xlsx_atual.aba; let lista_abas = column( abas.iter() .map(|aba| { let selecionada = aba == aba_atual; - let btn = if selecionada { - button(text(aba).size(14)) - .on_press(Message::AbaSelecionada(aba.clone())) - .width(Length::Fill) - } else { - button(text(aba).size(14)) - .on_press(Message::AbaSelecionada(aba.clone())) - .width(Length::Fill) - }; - btn.into() + let label = row![ + text(if selecionada { "●" } else { "○" }) + .size(12) + .color(if selecionada { + t::PRIMARY + } else { + t::TEXT_MUTED + }), + text(aba) + .size(14) + .color(if selecionada { t::PRIMARY } else { t::TEXT }), + ] + .spacing(8) + .align_y(Alignment::Center); + + button(label) + .on_press(Message::AbaSelecionada(aba.clone())) + .style(if selecionada { + t::btn_aba_ativa + } else { + t::btn_aba_inativa + }) + .width(Length::Fill) + .padding([8, 12]) + .into() }) .collect::>(), ) .spacing(4) .width(Length::Fill); - // Pré-visualização da aba selecionada - let preview_section: Element = if !aba_atual.is_empty() { - if let Some(linhas) = &app.preview_arquivo { - crate::ui::components::tabela_preview::tabela_preview(linhas) - } else { - text("(sem pré-visualização)").size(12).into() - } - } else { - text("Selecione uma aba para pré-visualizar.") - .size(12) - .into() - }; - - // Botões de ação - let tem_preset = !app.nome_layout_atual.is_empty(); - let aba_selecionada = !aba_atual.is_empty(); - - let mut botoes = row![button("< Voltar").on_press(Message::Voltar)].spacing(8); - - if aba_selecionada && tem_preset { - botoes = botoes.push(button("▶ Processar").on_press(Message::ExecutarImportacao)); - } - - if aba_selecionada { - botoes = botoes - .push(button("⚙ Configurar Colunas").on_press(Message::IrParaConfiguracaoColunas)); - } - - let content = column![ - titulo, - arquivo_label, - text("Selecione a aba a processar:").size(14), - scrollable(lista_abas).height(Length::Fixed(200.0)), - preview_section, - botoes, - ] - .spacing(12) + let card_abas = container( + column![ + text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY), + scrollable(lista_abas).height(Length::Fixed(220.0)), + ] + .spacing(10), + ) + .style(t::card) .padding(16) .width(Length::Fill); - container(content) + // ── Preview ─────────────────────────────────────────────────────────────── + let preview_section: Element = if !aba_atual.is_empty() { + if let Some(linhas) = &app.preview_arquivo { + container( + column![ + text(format!("Pré-visualização: {}", aba_atual)) + .size(13) + .color(t::TEXT_SECONDARY), + crate::ui::components::tabela_preview::tabela_preview(linhas), + ] + .spacing(8), + ) + .style(t::card) + .padding(16) + .width(Length::Fill) + .into() + } else { + container( + text("(sem pré-visualização disponível)") + .size(12) + .color(t::TEXT_MUTED), + ) + .style(t::card) + .padding(16) + .width(Length::Fill) + .into() + } + } else { + container( + text("Selecione uma aba para pré-visualizar o conteúdo.") + .size(13) + .color(t::TEXT_MUTED), + ) + .style(t::card) + .padding(16) + .width(Length::Fill) + .into() + }; + + // ── Botões de ação ──────────────────────────────────────────────────────── + let tem_preset = !app.nome_layout_atual.is_empty(); + let aba_selecionada = !aba_atual.is_empty(); + + let mut botoes = row![button(text("◀ Voltar").size(13)) + .on_press(Message::Voltar) + .style(t::btn_ghost) + .padding([9, 14]),] + .spacing(8) + .align_y(Alignment::Center); + + if aba_selecionada && tem_preset { + botoes = botoes.push( + button(text("Processar ▶").size(13)) + .on_press(Message::ExecutarImportacao) + .style(t::btn_primary) + .padding([9, 14]), + ); + } + + if aba_selecionada { + botoes = botoes.push( + button(text("⚙ Configurar Colunas").size(13)) + .on_press(Message::IrParaConfiguracaoColunas) + .style(t::btn_secondary) + .padding([9, 14]), + ); + } + + // ── Layout geral ────────────────────────────────────────────────────────── + let content = column![header, card_abas, preview_section, botoes] + .spacing(16) + .padding([20, 24]) + .width(Length::Fill); + + container(scrollable(content)) + .style(t::fundo) .width(Length::Fill) .height(Length::Fill) .into() diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..21258df --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,481 @@ +use iced::widget::{button, container, progress_bar, text_input}; +use iced::{Border, Color, Theme}; + +// ─── Paleta de cores ────────────────────────────────────────────────────────── + +pub const BG: Color = Color { + r: 0.059, + g: 0.090, + b: 0.165, + a: 1.0, +}; // #0F172A +pub const SURFACE: Color = Color { + r: 0.118, + g: 0.161, + b: 0.231, + a: 1.0, +}; // #1E293B +pub const SURFACE_2: Color = Color { + r: 0.200, + g: 0.255, + b: 0.333, + a: 1.0, +}; // #334155 +pub const BORDER: Color = Color { + r: 0.200, + g: 0.255, + b: 0.333, + a: 1.0, +}; // #334155 +pub const TEXT: Color = Color { + r: 0.945, + g: 0.961, + b: 0.980, + a: 1.0, +}; // #F1F5F9 +pub const TEXT_SECONDARY: Color = Color { + r: 0.580, + g: 0.639, + b: 0.722, + a: 1.0, +}; // #94A3B8 +pub const TEXT_MUTED: Color = Color { + r: 0.392, + g: 0.455, + b: 0.545, + a: 1.0, +}; // #64748B +pub const PRIMARY: Color = Color { + r: 0.231, + g: 0.510, + b: 0.965, + a: 1.0, +}; // #3B82F6 +pub const PRIMARY_HOVER: Color = Color { + r: 0.145, + g: 0.388, + b: 0.922, + a: 1.0, +}; // #2563EB +pub const SUCCESS: Color = Color { + r: 0.133, + g: 0.773, + b: 0.369, + a: 1.0, +}; // #22C55E +pub const WARNING: Color = Color { + r: 0.961, + g: 0.620, + b: 0.043, + a: 1.0, +}; // #F59E0B +pub const DANGER: Color = Color { + r: 0.937, + g: 0.267, + b: 0.267, + a: 1.0, +}; // #EF4444 +pub const TRACK_BG: Color = Color { + r: 0.067, + g: 0.094, + b: 0.153, + a: 1.0, +}; // #111827 + +// ─── Tema global ───────────────────────────────────────────────────────────── + +pub fn tema_dark() -> Theme { + Theme::custom( + "dark".to_string(), + iced::theme::Palette { + background: BG, + text: TEXT, + primary: PRIMARY, + success: SUCCESS, + danger: DANGER, + }, + ) +} + +// ─── Container styles ──────────────────────────────────────────────────────── + +/// Card principal: surface + borda + radius 8 +pub fn card(_theme: &Theme) -> container::Style { + container::Style { + background: Some(SURFACE.into()), + border: Border { + color: BORDER, + width: 1.0, + radius: 8.0.into(), + }, + text_color: Some(TEXT), + ..Default::default() + } +} + +/// Card secundário: surface_2 + borda + radius 6 +pub fn card_secondary(_theme: &Theme) -> container::Style { + container::Style { + background: Some(SURFACE_2.into()), + border: Border { + color: BORDER, + width: 1.0, + radius: 6.0.into(), + }, + text_color: Some(TEXT), + ..Default::default() + } +} + +/// Fundo geral da tela +pub fn fundo(_theme: &Theme) -> container::Style { + container::Style { + background: Some(BG.into()), + text_color: Some(TEXT), + ..Default::default() + } +} + +/// Cabeçalho de tabela (surface_2 sem borda) +pub fn cabecalho_tabela(_theme: &Theme) -> container::Style { + container::Style { + background: Some(SURFACE_2.into()), + text_color: Some(TEXT_SECONDARY), + ..Default::default() + } +} + +/// Badge de sucesso (OK) +pub fn badge_sucesso(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Color { a: 0.15, ..SUCCESS }.into()), + border: Border { + color: Color { a: 0.3, ..SUCCESS }, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(SUCCESS), + ..Default::default() + } +} + +/// Badge de aviso (faltante) +pub fn badge_aviso(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Color { a: 0.15, ..WARNING }.into()), + border: Border { + color: Color { a: 0.3, ..WARNING }, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(WARNING), + ..Default::default() + } +} + +/// Badge de perigo (duplicada) +pub fn badge_perigo(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Color { a: 0.15, ..DANGER }.into()), + border: Border { + color: Color { a: 0.3, ..DANGER }, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(DANGER), + ..Default::default() + } +} + +/// Área de erro/validação +pub fn area_erro(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Color { a: 0.10, ..DANGER }.into()), + border: Border { + color: Color { a: 0.4, ..DANGER }, + width: 1.0, + radius: 6.0.into(), + }, + text_color: Some(DANGER), + ..Default::default() + } +} + +/// Stat card com borda colorida no topo +pub fn stat_card(_theme: &Theme) -> container::Style { + container::Style { + background: Some(SURFACE.into()), + border: Border { + color: BORDER, + width: 1.0, + radius: 8.0.into(), + }, + text_color: Some(TEXT), + ..Default::default() + } +} + +/// Separador horizontal (linha fina) +pub fn separador(_theme: &Theme) -> container::Style { + container::Style { + background: Some(BORDER.into()), + ..Default::default() + } +} + +/// Fundo do breadcrumb +pub fn breadcrumb_bg(_theme: &Theme) -> container::Style { + container::Style { + background: Some(SURFACE.into()), + border: Border { + color: BORDER, + width: 0.0, + radius: 0.0.into(), + }, + text_color: Some(TEXT_SECONDARY), + ..Default::default() + } +} + +// ─── Button styles ─────────────────────────────────────────────────────────── + +/// Botão primário (azul sólido) +pub fn btn_primary(theme: &Theme, status: button::Status) -> button::Style { + let base = button::Style { + background: Some(PRIMARY.into()), + text_color: Color::WHITE, + border: Border { + radius: 6.0.into(), + ..Default::default() + }, + ..Default::default() + }; + match status { + button::Status::Hovered => button::Style { + background: Some(PRIMARY_HOVER.into()), + ..base + }, + button::Status::Pressed => button::Style { + background: Some( + Color { + r: 0.114, + g: 0.306, + b: 0.847, + a: 1.0, + } + .into(), + ), // #1D4ED8 + ..base + }, + button::Status::Disabled => button::Style { + background: Some(Color { a: 0.4, ..PRIMARY }.into()), + text_color: Color { + a: 0.5, + ..Color::WHITE + }, + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +/// Botão secundário (surface_2) +pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style { + let base = button::Style { + background: Some(SURFACE_2.into()), + text_color: TEXT, + border: Border { + radius: 6.0.into(), + ..Default::default() + }, + ..Default::default() + }; + match status { + button::Status::Hovered => button::Style { + background: Some( + Color { + r: 0.25, + g: 0.31, + b: 0.40, + a: 1.0, + } + .into(), + ), + ..base + }, + button::Status::Disabled => button::Style { + background: Some( + Color { + a: 0.5, + ..SURFACE_2 + } + .into(), + ), + text_color: Color { a: 0.4, ..TEXT }, + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +/// Botão ghost (sem fundo, borda sutil) +pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style { + let base = button::Style { + background: Some(Color::TRANSPARENT.into()), + text_color: TEXT_SECONDARY, + border: Border { + color: BORDER, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }; + match status { + button::Status::Hovered => button::Style { + background: Some(Color { a: 0.08, ..PRIMARY }.into()), + text_color: TEXT, + ..base + }, + button::Status::Disabled => button::Style { + text_color: TEXT_MUTED, + border: Border { + color: Color { a: 0.3, ..BORDER }, + ..base.border + }, + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +/// Botão de perigo (exclusão) +pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style { + let base = button::Style { + background: Some(Color { a: 0.15, ..DANGER }.into()), + text_color: DANGER, + border: Border { + color: Color { a: 0.3, ..DANGER }, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }; + match status { + button::Status::Hovered => button::Style { + background: Some(Color { a: 0.25, ..DANGER }.into()), + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +/// Botão de aba selecionada (tela selecionar_aba) +pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style { + let base = button::Style { + background: Some(Color { a: 0.20, ..PRIMARY }.into()), + text_color: PRIMARY, + border: Border { + color: PRIMARY, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }; + match status { + button::Status::Hovered => button::Style { + background: Some(Color { a: 0.30, ..PRIMARY }.into()), + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +/// Botão de aba inativa (tela selecionar_aba) +pub fn btn_aba_inativa(theme: &Theme, status: button::Status) -> button::Style { + btn_secondary(theme, status) +} + +/// Botão de itens por página (ativo) +pub fn btn_pagina_ativo(theme: &Theme, status: button::Status) -> button::Style { + btn_primary(theme, status) +} + +/// Botão de itens por página (inativo) +pub fn btn_pagina_inativo(theme: &Theme, status: button::Status) -> button::Style { + btn_ghost(theme, status) +} + +// ─── Text input styles ──────────────────────────────────────────────────────── + +pub fn input_dark(theme: &Theme, status: text_input::Status) -> text_input::Style { + let base = text_input::Style { + background: BG.into(), + border: Border { + color: BORDER, + width: 1.0, + radius: 6.0.into(), + }, + icon: TEXT_MUTED, + placeholder: TEXT_MUTED, + value: TEXT, + selection: Color { a: 0.3, ..PRIMARY }, + }; + match status { + text_input::Status::Focused => text_input::Style { + border: Border { + color: PRIMARY, + ..base.border + }, + ..base + }, + text_input::Status::Hovered => text_input::Style { + border: Border { + color: TEXT_SECONDARY, + ..base.border + }, + ..base + }, + _ => { + let _ = theme; + base + } + } +} + +// ─── Progress bar styles ───────────────────────────────────────────────────── + +/// Progress bar colorida por percentual (0.0 – 1.0) +pub fn progress_bar_por_percentual(percentual: f32) -> impl Fn(&Theme) -> progress_bar::Style { + move |_theme| { + let cor = if percentual >= 0.90 { + SUCCESS + } else if percentual >= 0.60 { + WARNING + } else { + DANGER + }; + progress_bar::Style { + background: TRACK_BG.into(), + bar: cor.into(), + border: Border { + radius: 10.0.into(), + ..Default::default() + }, + } + } +}