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:
+291
@@ -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
|
||||
@@ -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)),
|
||||
|
||||
+27
-15
@@ -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<Element<'_, Message>> = 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()
|
||||
}
|
||||
|
||||
+83
-23
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<String>]) -> 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::<Vec<_>>())
|
||||
.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::<String>())
|
||||
} 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::<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().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::<String>())
|
||||
} 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::<Vec<_>>())
|
||||
.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<String>]) -> Element<'_, Message> {
|
||||
.chain(linhas_view)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod app;
|
||||
pub mod components;
|
||||
pub mod message;
|
||||
pub mod screens;
|
||||
pub mod theme;
|
||||
|
||||
@@ -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<String> = 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<Message> = 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<Message> = 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::<Vec<_>>(),
|
||||
container(
|
||||
Column::with_children(
|
||||
erros
|
||||
.iter()
|
||||
.map(|e| text(format!("⚠ {}", e)).size(13).color(t::DANGER).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.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<String> = 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::<usize>()
|
||||
.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::<usize>()
|
||||
.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::<usize>()
|
||||
.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<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()
|
||||
}
|
||||
|
||||
@@ -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::<usize>().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<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>(
|
||||
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<String>) {
|
||||
let mut erros = Vec::new();
|
||||
|
||||
|
||||
+141
-27
@@ -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<Message> = 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<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 opcoes_layout: Vec<String> = 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<Message> = if app.caminho_arquivo.is_some() {
|
||||
button("▶ Configurar Colunas")
|
||||
// ── Botão avançar ─────────────────────────────────────────────────────────
|
||||
let botao_avancar: Element<Message> = 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()
|
||||
}
|
||||
|
||||
+56
-25
@@ -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()
|
||||
}
|
||||
|
||||
+216
-67
@@ -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::<Vec<_>>())
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
)
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Pré-visualização da aba selecionada
|
||||
let preview_section: Element<Message> = 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<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)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
|
||||
+481
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user