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.
This commit is contained in:
FelipeCN
2026-03-04 10:58:54 -03:00
parent 8d00cfc4d7
commit ad09c53a5d
13 changed files with 1820 additions and 430 deletions
+291
View File
@@ -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 6089% |
| `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`
- 6089% → `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.01.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
+1
View File
@@ -9,6 +9,7 @@ use ui::app::App;
fn main() -> iced::Result { fn main() -> iced::Result {
iced::application("Comparador de Notas", App::update, App::view) iced::application("Comparador de Notas", App::update, App::view)
.theme(|_app| ui::theme::tema_dark())
.window(iced::window::Settings { .window(iced::window::Settings {
size: iced::Size::new(1024.0, 768.0), size: iced::Size::new(1024.0, 768.0),
min_size: Some(iced::Size::new(800.0, 600.0)), min_size: Some(iced::Size::new(800.0, 600.0)),
+27 -15
View File
@@ -13,6 +13,7 @@ use crate::domain::{
}; };
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations}; use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use crate::ui::message::{Message, ResultadoPendente}; use crate::ui::message::{Message, ResultadoPendente};
use crate::ui::theme as t;
use iced::widget::{column, container, text, Row}; use iced::widget::{column, container, text, Row};
use iced::{Alignment, Element, Length, Task}; use iced::{Alignment, Element, Length, Task};
use rusqlite::Connection; use rusqlite::Connection;
@@ -680,10 +681,13 @@ impl App {
EstadoApp::Analisando => { EstadoApp::Analisando => {
container( container(
column![ 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), .align_x(Alignment::Center),
) )
.style(t::fundo)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.center_x(Length::Fill) .center_x(Length::Fill)
@@ -701,7 +705,11 @@ impl App {
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} else { } else {
conteudo container(conteudo)
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}; };
// Modal por cima de tudo // Modal por cima de tudo
@@ -722,31 +730,35 @@ impl App {
_ => 1, _ => 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<Element<'_, Message>> = Vec::new(); let mut items: Vec<Element<'_, Message>> = Vec::new();
for (i, (label, n)) in passos.iter().enumerate() { for (i, (label, n)) in passos.iter().enumerate() {
let t = if *n == passo_ativo { let elem: Element<'_, Message> = if *n == passo_ativo {
// Active step: bold-weight via larger size + primary color container(text(*label).size(13).color(t::PRIMARY))
text(*label).size(15).color(iced::Color::from_rgb(0.15, 0.5, 0.9)) .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 { } else if *n < passo_ativo {
// Completed step: muted color text(*label).size(13).color(t::TEXT_SECONDARY).into()
text(*label).size(14).color(iced::Color::from_rgb(0.5, 0.5, 0.5))
} else { } else {
// Future step: even more muted text(*label).size(13).color(t::TEXT_MUTED).into()
text(*label).size(14).color(iced::Color::from_rgb(0.6, 0.6, 0.6))
}; };
items.push(t.into()); items.push(elem);
if i < passos.len() - 1 { 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( container(
Row::with_children(items) Row::with_children(items).align_y(Alignment::Center),
.align_y(Alignment::Center),
) )
.padding([4, 8]) .style(t::breadcrumb_bg)
.padding([10, 20])
.width(Length::Fill) .width(Length::Fill)
.into() .into()
} }
+83 -23
View File
@@ -1,5 +1,6 @@
use crate::ui::app::EstadoModal; use crate::ui::app::EstadoModal;
use crate::ui::message::Message; 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::widget::{button, column, container, mouse_area, row, stack, text, text_input};
use iced::{Alignment, Color, Element, Length}; use iced::{Alignment, Color, Element, Length};
@@ -14,7 +15,7 @@ pub fn view_com_modal<'a>(
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(|_theme| container::Style { .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() ..Default::default()
}) })
.center_x(Length::Fill) .center_x(Length::Fill)
@@ -27,59 +28,118 @@ pub fn view_com_modal<'a>(
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> { fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal { match modal {
EstadoModal::Informacao { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false), EstadoModal::Informacao { titulo, mensagem } => {
EstadoModal::Aviso { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false), caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
EstadoModal::Erro { titulo, mensagem } => caixa_modal(titulo, mensagem, 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 { EstadoModal::Confirmacao {
titulo, mensagem, .. titulo, mensagem, ..
} => caixa_modal(titulo, mensagem, None, true), } => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true),
EstadoModal::InputTexto { EstadoModal::InputTexto {
titulo, titulo,
mensagem, mensagem,
texto, 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>( fn caixa_modal<'a>(
titulo: &'a str, titulo: &'a str,
mensagem: &'a str, mensagem: &'a str,
tipo: TipoModal,
input: Option<&'a str>, input: Option<&'a str>,
com_confirmar: bool, com_confirmar: bool,
) -> Element<'a, Message> { ) -> 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 { if let Some(valor) = input {
col = col.push( col = col.push(
text_input("Nome...", valor) text_input("Nome...", valor)
.on_input(Message::ModalTextoAlterado) .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 { 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); col = col.push(botoes);
container(col.align_x(Alignment::Start)) container(col.align_x(Alignment::Start))
.width(Length::Fixed(400.0)) .width(Length::Fixed(420.0))
.padding(24) .padding(24)
.style(|theme: &iced::Theme| { .style(t::card)
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()
}
})
.into() .into()
} }
+13 -5
View File
@@ -1,6 +1,7 @@
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, row, text}; use iced::widget::{button, row, text};
use iced::Element; use iced::{Alignment, Element};
/// Renderiza controles de paginação reutilizáveis. /// Renderiza controles de paginação reutilizáveis.
pub fn controles_paginacao( pub fn controles_paginacao(
@@ -9,15 +10,22 @@ pub fn controles_paginacao(
msg_anterior: Message, msg_anterior: Message,
msg_proximo: Message, msg_proximo: Message,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let btn_anterior = button("").on_press_maybe((pagina_atual > 0).then_some(msg_anterior)); let btn_anterior = button("")
let btn_proximo = .on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
button("").on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo)); .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![ row![
btn_anterior, 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, btn_proximo,
] ]
.spacing(8) .spacing(8)
.align_y(Alignment::Center)
.into() .into()
} }
+59 -12
View File
@@ -1,28 +1,59 @@
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::screens::indice_para_letra; use crate::ui::screens::indice_para_letra;
use iced::widget::{column, row, scrollable, text}; use crate::ui::theme as t;
use iced::{Element, Font, Length}; 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. /// 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. /// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados.
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> { pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0); let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 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) // Cabeçalho estilo Excel — fundo SURFACE_2
let cabecalho = container(
row((0..num_colunas)
.map(|i| { .map(|i| {
container(
text(format!("{} ({})", indice_para_letra(i), i)) text(format!("{} ({})", indice_para_letra(i), i))
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.size(12) .size(12)
.color(t::TEXT_SECONDARY),
)
.width(Length::Fixed(120.0)) .width(Length::Fixed(120.0))
.padding([4, 6])
.into() .into()
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
.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().map(|linha| { 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) row((0..num_colunas)
.map(|col| { .map(|col| {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or(""); let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
@@ -31,14 +62,19 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
} else { } else {
celula.to_string() celula.to_string()
}; };
text(truncado) container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
.font(Font::MONOSPACE)
.size(11)
.width(Length::Fixed(120.0)) .width(Length::Fixed(120.0))
.padding([3, 6])
.into() .into()
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
.spacing(4) .spacing(0),
)
.width(Length::Shrink)
.style(move |_theme| iced::widget::container::Style {
background: Some(bg.into()),
..Default::default()
})
.into() .into()
}); });
@@ -47,12 +83,23 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.chain(linhas_view) .chain(linhas_view)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.spacing(2); .spacing(0);
container(
scrollable(todas_linhas) scrollable(todas_linhas)
.direction(scrollable::Direction::Horizontal( .direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(), scrollable::Scrollbar::default(),
)) ))
.height(Length::Fixed(160.0)) .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() .into()
} }
+1
View File
@@ -2,3 +2,4 @@ pub mod app;
pub mod components; pub mod components;
pub mod message; pub mod message;
pub mod screens; pub mod screens;
pub mod theme;
+274 -156
View File
@@ -1,6 +1,7 @@
use crate::domain::entities::layout::TipoArquivo; use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{ use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column, 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. /// Tela de configuração de colunas.
pub fn view(app: &App) -> Element<'_, Message> { pub fn view(app: &App) -> Element<'_, Message> {
let titulo = text("Configuração de Colunas").size(22); // ── Cabeçalho ─────────────────────────────────────────────────────────────
let header = column![
let arquivo_label = if let Some(caminho) = &app.caminho_arquivo { text("Configuração de Colunas").size(20).color(t::TEXT),
text(format!("Arquivo: {}", caminho.display())).size(13) 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 { } else {
text("").size(13) row![text("").size(12)]
}; },
]
.spacing(4);
// Seletor de layout // ── Seletor de layout ──────────────────────────────────────────────────────
let tipo_atual = app.tipo_arquivo_atual.clone(); let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<String> = app let opcoes_layout: Vec<String> = app
.layouts_salvos .layouts_salvos
@@ -38,12 +54,15 @@ pub fn view(app: &App) -> Element<'_, Message> {
Some(app.nome_layout_atual.clone()) Some(app.nome_layout_atual.clone())
}; };
let secao_layout = row![ let secao_layout = container(
text("Layout:").size(14), row![
text("Layout:")
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::Fixed(80.0)),
pick_list(opcoes_layout, nome_layout_sel, { pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone(); let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| { move |nome_selecionado: String| {
// Busca o id do layout pelo nome e dispara LayoutSelecionado
if let Some(id) = layouts if let Some(id) = layouts
.iter() .iter()
.find(|l| l.nome() == nome_selecionado) .find(|l| l.nome() == nome_selecionado)
@@ -56,77 +75,148 @@ pub fn view(app: &App) -> Element<'_, Message> {
} }
}) })
.placeholder("— Selecionar layout —") .placeholder("— Selecionar layout —")
.width(Length::Fixed(240.0)), .style(input_style_pick)
.width(Length::Fixed(260.0)),
] ]
.spacing(8) .spacing(8)
.align_y(Alignment::Center); .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 { let config_section = match app.tipo_arquivo_atual {
TipoArquivo::Csv => view_csv(app), TipoArquivo::Csv => view_csv(app),
TipoArquivo::Xlsx => view_xlsx(app), TipoArquivo::Xlsx => view_xlsx(app),
}; };
// Pré-visualização // ── Pré-visualização ──────────────────────────────────────────────────────
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo { let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
container(
column![ column![
text("Pré-visualização:").size(13), text("Pré-visualização (5 primeiras linhas)")
.size(13)
.color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas), crate::ui::components::tabela_preview::tabela_preview(linhas),
] ]
.spacing(4) .spacing(8),
)
.style(t::card)
.padding(14)
.width(Length::Fill)
.into() .into()
} else { } else {
text("").into() text("").size(1).into()
}; };
// Validação // ── Validação ─────────────────────────────────────────────────────────────
let (valido, erros) = validar_config(app); let (valido, erros) = validar_config(app);
let erros_section: Element<Message> = if erros.is_empty() { let erros_section: Element<Message> = if erros.is_empty() {
text("").into() text("").size(1).into()
} else { } else {
container(
Column::with_children( Column::with_children(
erros erros
.iter() .iter()
.map(|e| text(format!("{}", e)).size(13).into()) .map(|e| text(format!(" {}", e)).size(13).color(t::DANGER).into())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.spacing(2) .spacing(4),
)
.style(t::area_erro)
.padding([10, 14])
.width(Length::Fill)
.into() .into()
}; };
// ── Botões ────────────────────────────────────────────────────────────────
let tem_arquivo = app.caminho_arquivo.is_some(); let tem_arquivo = app.caminho_arquivo.is_some();
let tem_notas = !app.notas_importadas.is_empty(); let tem_notas = !app.notas_importadas.is_empty();
let botoes = row![ let botoes = container(
button("< Voltar").on_press(Message::Voltar), row![
button("▶ Importar e Analisar") button(text("◀ Voltar").size(13))
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao)), .on_press(Message::Voltar)
button("🔄 Reanalisar") .style(t::btn_ghost)
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo)), .padding([9, 14]),
button("💾 Salvar como layout...").on_press(Message::SalvarLayout), 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); .spacing(8)
.align_y(Alignment::Center),
)
.style(t::card_secondary)
.padding([12, 16])
.width(Length::Fill);
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![ let content = column![
titulo, header,
arquivo_label,
secao_layout, secao_layout,
config_section, config_section,
preview_section, preview_section,
erros_section, erros_section,
botoes, botoes,
] ]
.spacing(12) .spacing(14)
.padding(16) .padding([20, 24])
.width(Length::Fill); .width(Length::Fill);
container(scrollable(content)) container(scrollable(content))
.style(t::fundo)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .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> { fn view_csv(app: &App) -> Element<'_, Message> {
let c = &app.layout_csv_atual; let c = &app.layout_csv_atual;
@@ -142,17 +232,16 @@ fn view_csv(app: &App) -> Element<'_, Message> {
.iter() .iter()
.map(|(s, _)| s.to_string()) .map(|(s, _)| s.to_string())
.collect(); .collect();
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect(); let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
let linha_cabecalho_str = c.linha_cabecalho.to_string(); let linha_cabecalho_str = c.linha_cabecalho.to_string();
let indice_numero_str = c.indice_numero.to_string(); let indice_numero_str = c.indice_numero.to_string();
let indice_serie_str = c.indice_serie.to_string(); let indice_serie_str = c.indice_serie.to_string();
column![ let inner = column![
text("Configurações CSV").size(16), secao_titulo("Importação CSV"),
row![ campo_row(
text("Delimitador:").size(14).width(Length::Fixed(220.0)), "Delimitador",
pick_list(opcoes_delim, Some(delim_str), |selecionado| { pick_list(opcoes_delim, Some(delim_str), |selecionado| {
let c = OPCOES_DELIMITADOR let c = OPCOES_DELIMITADOR
.iter() .iter()
@@ -161,84 +250,177 @@ fn view_csv(app: &App) -> Element<'_, Message> {
.unwrap_or(','); .unwrap_or(',');
Message::DelimitadorAlterado(c) Message::DelimitadorAlterado(c)
}) })
.width(Length::Fixed(200.0)), .style(input_style_pick)
] .width(Length::Fixed(200.0))
.spacing(8) .into(),
.align_y(Alignment::Center), ),
row![ campo_row(
text("Encoding:").size(14).width(Length::Fixed(220.0)), "Encoding",
pick_list( pick_list(
opcoes_enc, opcoes_enc,
Some(c.encoding.clone()), Some(c.encoding.clone()),
Message::EncodingAlterado Message::EncodingAlterado
) )
.width(Length::Fixed(200.0)), .style(input_style_pick)
] .width(Length::Fixed(200.0))
.spacing(8) .into(),
.align_y(Alignment::Center), ),
row![ campo_row(
text("Linha do cabeçalho:") "Linha cabeçalho",
.size(14)
.width(Length::Fixed(220.0)),
text_input("0", &linha_cabecalho_str) text_input("0", &linha_cabecalho_str)
.on_input(|s| { .on_input(|s| {
s.parse::<usize>() s.parse::<usize>()
.map(Message::LinhaCabecalhoAlterada) .map(Message::LinhaCabecalhoAlterada)
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.width(Length::Fixed(80.0)), .style(t::input_dark)
] .width(Length::Fixed(80.0))
.spacing(8) .into(),
.align_y(Alignment::Center), ),
text("Mapeamento de Colunas (índice base 0)").size(14), secao_subtitulo("Mapeamento de colunas (índice base 0)"),
row![ campo_row(
text("Número (obrigatório):") "Número (obrigatório)",
.size(14)
.width(Length::Fixed(220.0)),
text_input("0", &indice_numero_str) text_input("0", &indice_numero_str)
.on_input(|s| { .on_input(|s| {
s.parse::<usize>() s.parse::<usize>()
.map(Message::IndiceNumeroAlterado) .map(Message::IndiceNumeroAlterado)
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.width(Length::Fixed(80.0)), .style(t::input_dark)
] .width(Length::Fixed(80.0))
.spacing(8) .into(),
.align_y(Alignment::Center), ),
row![ campo_row(
text("Série (obrigatório):") "Série (obrigatório)",
.size(14)
.width(Length::Fixed(220.0)),
text_input("0", &indice_serie_str) text_input("0", &indice_serie_str)
.on_input(|s| { .on_input(|s| {
s.parse::<usize>() s.parse::<usize>()
.map(Message::IndiceSerieAlterado) .map(Message::IndiceSerieAlterado)
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.width(Length::Fixed(80.0)), .style(t::input_dark)
] .width(Length::Fixed(80.0))
.spacing(8) .into(),
.align_y(Alignment::Center), ),
campo_indice_opcional_csv( campo_indice_opcional_csv(
"Valor (opcional):", "Valor (opcional)",
c.indice_valor, c.indice_valor,
Message::IndiceValorToggle, Message::IndiceValorToggle,
Message::IndiceValorAlterado, Message::IndiceValorAlterado,
), ),
campo_indice_opcional_csv( campo_indice_opcional_csv(
"Data (opcional):", "Data (opcional)",
c.indice_data, c.indice_data,
Message::IndiceDataToggle, Message::IndiceDataToggle,
Message::IndiceDataAlterado, Message::IndiceDataAlterado,
), ),
campo_indice_opcional_csv( campo_indice_opcional_csv(
"Tipo Documento (opcional):", "Tipo Documento (opcional)",
c.indice_documento_tipo, c.indice_documento_tipo,
Message::IndiceDocTipoToggle, Message::IndiceDocTipoToggle,
Message::IndiceDocTipoAlterado, 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<Message> = 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() .into()
} }
@@ -251,16 +433,17 @@ fn campo_indice_opcional_csv(
let ativo = valor.is_some(); let ativo = valor.is_some();
let val_str = valor.map(|v| v.to_string()).unwrap_or_default(); 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 { if ativo {
row![ row![
cb.width(Length::Fixed(220.0)), cb.width(Length::Fixed(220.0)),
text_input("0", &val_str) text_input("0", &val_str)
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) }) .on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
.style(t::input_dark)
.width(Length::Fixed(80.0)), .width(Length::Fixed(80.0)),
] ]
.spacing(8) .spacing(10)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.into() .into()
} else { } 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<Message> = 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>( fn campo_letra_linha<'a>(
label: &'a str, label: &'a str,
valor: &'a str, valor: &'a str,
msg: impl Fn(String) -> Message + 'a, msg: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
row![ campo_row(
text(label).size(14).width(Length::Fixed(200.0)), label,
text_input("ex: B3", valor) text_input("ex: B3", valor)
.on_input(msg) .on_input(msg)
.width(Length::Fixed(100.0)), .style(t::input_dark)
] .width(Length::Fixed(100.0))
.spacing(8) .into(),
.align_y(Alignment::Center) )
.into()
} }
fn campo_letra_linha_opcional<'a>( fn campo_letra_linha_opcional<'a>(
@@ -359,16 +475,17 @@ fn campo_letra_linha_opcional<'a>(
let ativo = valor.is_some(); let ativo = valor.is_some();
let val_str = valor.unwrap_or("").to_string(); 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 { if ativo {
row![ row![
cb.width(Length::Fixed(200.0)), cb.width(Length::Fixed(220.0)),
text_input("ex: B3", &val_str) text_input("ex: B3", &val_str)
.on_input(msg_valor) .on_input(msg_valor)
.style(t::input_dark)
.width(Length::Fixed(100.0)), .width(Length::Fixed(100.0)),
] ]
.spacing(8) .spacing(10)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.into() .into()
} else { } 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<String>) { fn validar_config(app: &App) -> (bool, Vec<String>) {
let mut erros = Vec::new(); let mut erros = Vec::new();
+137 -23
View File
@@ -1,27 +1,97 @@
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, pick_list, row, text}; use iced::widget::{button, column, container, pick_list, row, text};
use iced::{Alignment, Element, Length}; use iced::{Alignment, Element, Length};
/// Tela de importação de arquivos. /// Tela de importação de arquivos.
pub fn view(app: &App) -> Element<'_, Message> { pub fn view(app: &App) -> Element<'_, Message> {
let titulo = text("Comparador de Notas — Importar Arquivo").size(22); // ── Área de seleção de arquivo ────────────────────────────────────────────
let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() {
// Nome do arquivo selecionado ("Nenhum arquivo selecionado".to_string(), false)
let nome = if app.nome_arquivo.is_empty() {
"Nenhum arquivo selecionado".to_string()
} else { } else {
app.nome_arquivo.clone() (app.nome_arquivo.clone(), true)
}; };
let secao_arquivo = row![ let icone_arquivo: Element<Message> = text(if tem_arquivo { "📄" } else { "📂" })
text(nome).size(14).width(Length::Fill), .size(32)
button("Selecionar arquivo...").on_press(Message::SelecionarArquivo), .color(if tem_arquivo {
] t::PRIMARY
.spacing(8) } else {
.align_y(Alignment::Center); t::TEXT_MUTED
})
.into();
// Seletor de layout compatível com o tipo atual let texto_arquivo: Element<Message> = 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 tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<String> = app let opcoes_layout: Vec<String> = app
.layouts_salvos .layouts_salvos
@@ -37,7 +107,10 @@ pub fn view(app: &App) -> Element<'_, Message> {
}; };
let secao_layout = row![ 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, { pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone(); let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| { move |nome_selecionado: String| {
@@ -53,28 +126,69 @@ pub fn view(app: &App) -> Element<'_, Message> {
} }
}) })
.placeholder("— Selecionar layout —") .placeholder("— Selecionar layout —")
.width(Length::Fixed(260.0)), .width(Length::Fill),
button("⚙ Gerenciar Layouts").on_press(Message::IrParaLayouts), button(text("Gerenciar").size(13))
.on_press(Message::IrParaLayouts)
.style(t::btn_ghost)
.padding([8, 12]),
] ]
.spacing(8) .spacing(8)
.align_y(Alignment::Center); .align_y(Alignment::Center);
// Botão de avançar só aparece quando um arquivo foi selecionado // ── Botão avançar ─────────────────────────────────────────────────────────
let botao_avancar: Element<Message> = if app.caminho_arquivo.is_some() { let botao_avancar: Element<Message> = if tem_arquivo {
button("Configurar Colunas") button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center))
.on_press(Message::IrParaConfiguracaoColunas) .on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_primary)
.width(Length::Fill)
.padding([11, 16])
.into() .into()
} else { } else {
text("").into() container(text("").size(1))
.height(Length::Fixed(42.0))
.into()
}; };
let content = column![titulo, secao_arquivo, secao_layout, botao_avancar] // ── Separador visual ──────────────────────────────────────────────────────
.spacing(16) 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) .padding(24)
.width(Length::Fill); .width(Length::Fill);
container(content) let card = container(card_inner)
.style(t::card)
.max_width(560)
.width(Length::Fill);
// ── 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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.center_x(Length::Fill)
.into() .into()
} }
+53 -22
View File
@@ -1,34 +1,39 @@
use crate::domain::entities::layout::{Layout, TipoArquivo}; use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text}; use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
use iced::{Alignment, Element, Length}; use iced::{Alignment, Element, Length};
/// Tela de gerenciamento de layouts. /// Tela de gerenciamento de layouts.
pub fn view(app: &App) -> Element<'_, Message> { 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_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx); let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
// Importar de JSON let content = column![titulo, cabecalho, secao_csv, secao_xlsx,]
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,]
.spacing(16) .spacing(16)
.padding(16) .padding(20)
.width(Length::Fill); .width(Length::Fill);
container(scrollable(content)) container(scrollable(content))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(t::fundo)
.into() .into()
} }
@@ -37,30 +42,56 @@ fn view_secao_layouts<'a>(
layouts: &'a [Layout], layouts: &'a [Layout],
tipo: TipoArquivo, tipo: TipoArquivo,
) -> Element<'a, Message> { ) -> 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 filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
let mut col = column![titulo_widget].spacing(4);
if filtrados.is_empty() { if filtrados.is_empty() {
col = col.push(text("(nenhum layout salvo)").size(13)); col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED));
return col.into(); return container(col)
.padding([12, 16])
.width(Length::Fill)
.style(t::card)
.into();
} }
for layout in filtrados { for layout in filtrados {
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
let linha = row![ let linha = container(
text(layout.nome()).size(14).width(Length::Fill), row![
text(layout.nome())
.size(14)
.color(t::TEXT)
.width(Length::Fill),
horizontal_space(), horizontal_space(),
button("📂 Carregar").on_press(Message::LayoutSelecionado(id)), button("Carregar")
button("📤 Exportar JSON").on_press(Message::ExportarLayoutJson(id)), .on_press(Message::LayoutSelecionado(id))
button("🗑 Excluir").on_press(Message::ExcluirLayout(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) .spacing(8)
.align_y(Alignment::Center); .align_y(Alignment::Center)
.padding([10, 0]),
)
.width(Length::Fill);
col = col.push(linha); 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()
} }
+206 -57
View File
@@ -4,73 +4,135 @@ use crate::domain::{
}; };
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; 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}; use iced::{Alignment, Element, Length};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
/// Tela de resultados da análise. /// Tela de resultados da análise.
pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> { 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![ let botoes_topo = row![
button("< Nova Análise").on_press(Message::NovaAnalise), // abre modal de confirmação button("< Nova Análise")
button("⚙ Reconfigurar Colunas").on_press(Message::IrParaConfiguracaoColunas), .on_press(Message::NovaAnalise)
button("🔄 Reanalisar Arquivo").on_press_maybe( .style(t::btn_secondary),
button("Reconfigurar Colunas")
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_ghost),
button("Reanalisar Arquivo")
.on_press_maybe(
app.caminho_arquivo app.caminho_arquivo
.as_ref() .as_ref()
.map(|_| Message::ReanalisarArquivo) .map(|_| Message::ReanalisarArquivo)
), )
button("📄 Exportar PDF").on_press(Message::ExportarPdf), .style(t::btn_ghost),
button("Exportar PDF")
.on_press(Message::ExportarPdf)
.style(t::btn_primary),
] ]
.spacing(8); .spacing(8);
// Controle de itens por página // ── Controle de itens por página ──────────────────────────────────────────
let opcoes_por_pagina = row(OPCOES_PAGINA let opcoes_por_pagina = row(OPCOES_PAGINA
.iter() .iter()
.map(|&n| { .map(|&n| {
let ativo = n == app.itens_por_pagina;
button(text(n.to_string()).size(13)) button(text(n.to_string()).size(13))
.on_press(Message::ItensPorPaginaAlterado(n)) .on_press(Message::ItensPorPaginaAlterado(n))
.style(if ativo {
t::btn_pagina_ativo
} else {
t::btn_pagina_inativo
})
.into() .into()
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
.spacing(4); .spacing(4);
let controle_pagina = row![text("Itens por página:").size(13), opcoes_por_pagina,] let controle_pagina = row![
text("Itens por página:").size(13).color(t::TEXT_SECONDARY),
opcoes_por_pagina,
]
.spacing(8) .spacing(8)
.align_y(Alignment::Center); .align_y(Alignment::Center);
// Seções principais // ── Seções principais ─────────────────────────────────────────────────────
let secao_faltantes = view_faltantes(app, resultado); let secao_faltantes = view_faltantes(app, resultado);
let secao_duplicatas = view_duplicatas(app, resultado); let secao_duplicatas = view_duplicatas(app, resultado);
let secao_totais = view_totais(resultado); let secao_totais = view_totais(resultado);
let conteudo = column![ let conteudo = column![
titulo, row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo, botoes_topo,
stat_row,
controle_pagina, controle_pagina,
secao_faltantes, secao_faltantes,
secao_duplicatas, secao_duplicatas,
secao_totais, secao_totais,
] ]
.spacing(16) .spacing(16)
.padding(16) .padding(20)
.width(Length::Fill); .width(Length::Fill);
container(scrollable(conteudo)) container(scrollable(conteudo))
.width(Length::Fill) .width(Length::Fill)
.height(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() .into()
} }
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> { fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_faltantes(); 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 { if total == 0 {
col = col.push(text("Nenhuma nota faltante.").size(14)); col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS));
return col.into(); return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
} }
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect(); 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_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len(); 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![ let cabecalho_serie = row![
column![
text(format!("Série {}", chave.label()))
.size(14)
.color(t::TEXT),
text(format!( text(format!(
"Série {}{} faltante(s) — {}/{} notas ({:.1}% completo):", "{} faltante(s) — {}/{} notas ({:.1}% completo)",
chave.label(),
faltantes.len(), faltantes.len(),
total_notas, total_notas,
total_esperado, total_esperado,
percentual, percentual * 100.0,
)) ))
.size(14) .size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(2)
.width(Length::Fill), .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) .spacing(8)
.align_y(Alignment::Center); .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 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 pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina; let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); 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]) { for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
if a == b { let txt = if a == b {
col = col.push(text(format!(" {}", a)).size(13)); text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY)
} else { } 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 { if total_paginas > 1 {
col = col.push(crate::ui::components::paginacao::controles_paginacao( 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), 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> { fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_duplicatas(); 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 { if total == 0 {
col = col.push(text("Nenhuma nota duplicada.").size(14)); col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS));
return col.into(); return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
} }
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect(); 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![ let cabecalho_serie = row![
text(format!( text(format!(
"Série {}{} grupo(s) duplicado(s):", "Série {}{} grupo(s) duplicado(s)",
chave.label(), chave.label(),
duplicatas.len() duplicatas.len()
)) ))
.size(14) .size(14)
.color(t::TEXT)
.width(Length::Fill), .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) .spacing(8)
.align_y(Alignment::Center); .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 inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
let mut lista = column![].spacing(2);
for (numero, count) in &duplicatas[inicio..fim] { for (numero, count) in &duplicatas[inicio..fim] {
col = col.push( lista = lista.push(
text(format!( text(format!(
" NF {} / Série {}{} ocorrências", " NF {} / Série {}{} ocorrências",
numero, numero,
chave.label(), chave.label(),
count count
)) ))
.size(13), .size(13)
.color(t::TEXT_SECONDARY),
); );
} }
col = col.push(lista);
if total_paginas > 1 { if total_paginas > 1 {
col = col.push(crate::ui::components::paginacao::controles_paginacao( 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), 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> { 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( let mut col = column![titulo].spacing(8);
text(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
))
.size(14),
);
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect(); let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.sort(); chaves.sort();
@@ -212,16 +329,48 @@ fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
for chave in chaves { for chave in chaves {
let soma = &resultado.soma_por_serie[chave]; let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
col = col.push(
text(format!( let linha = row![
" Série {}: {} nota(s) — R$ {}", text(format!("Série {}", chave.label()))
chave.label(), .size(14)
total_notas, .color(t::TEXT)
formatar_valor_br(soma) .width(Length::Fill),
)) text(format!("{} nota(s)", total_notas))
.size(13), .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()
} }
+125 -48
View File
@@ -1,78 +1,155 @@
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, row, scrollable, text}; use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Element, Length}; use iced::{Alignment, Element, Length};
use std::path::PathBuf; use std::path::PathBuf;
/// Tela de seleção de aba de arquivo XLSX. /// 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> { 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 aba_atual = &app.layout_xlsx_atual.aba;
let lista_abas = column( let lista_abas = column(
abas.iter() abas.iter()
.map(|aba| { .map(|aba| {
let selecionada = aba == aba_atual; let selecionada = aba == aba_atual;
let btn = if selecionada { let label = row![
button(text(aba).size(14)) text(if selecionada { "" } else { "" })
.on_press(Message::AbaSelecionada(aba.clone())) .size(12)
.width(Length::Fill) .color(if selecionada {
t::PRIMARY
} else { } else {
button(text(aba).size(14)) 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())) .on_press(Message::AbaSelecionada(aba.clone()))
.style(if selecionada {
t::btn_aba_ativa
} else {
t::btn_aba_inativa
})
.width(Length::Fill) .width(Length::Fill)
}; .padding([8, 12])
btn.into() .into()
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.spacing(4) .spacing(4)
.width(Length::Fill); .width(Length::Fill);
// Pré-visualização da aba selecionada let card_abas = container(
let preview_section: Element<Message> = if !aba_atual.is_empty() { column![
if let Some(linhas) = &app.preview_arquivo { text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas) scrollable(lista_abas).height(Length::Fixed(220.0)),
} 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) .spacing(10),
)
.style(t::card)
.padding(16) .padding(16)
.width(Length::Fill); .width(Length::Fill);
container(content) // ── Preview ───────────────────────────────────────────────────────────────
let preview_section: Element<Message> = 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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
+481
View File
@@ -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()
},
}
}
}