Compare commits

...
3 Commits
Author SHA1 Message Date
Felipe 4c4f573cd1 Fix button colors. 2026-03-04 22:46:27 -03:00
Felipe 857fcd6a82 Fix SalvarLayoutConfig 2026-03-04 21:45:52 -03:00
Felipe 841dfadde5 update 2026-03-04 18:33:32 -03:00
12 changed files with 251 additions and 530 deletions
-76
View File
@@ -2,40 +2,6 @@
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado. > Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Alta Prioridade
### F-01 — Exportar Resultado em CSV.
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
- `faltantes.csv`: `serie,documento_tipo,numero`
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
**Escopo técnico:**
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
- Botão na barra de ações da tela `resultado.rs`
- Dependência `csv` já está no `Cargo.toml`
**Esforço estimado:** Médio (23h)
**Impacto:** Alto — elimina retrabalho manual
**Observações:**
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementado)
**Status:** Já implementado. Ambos `csv_reader.rs` e `xlsx_reader.rs` verificam o tamanho do arquivo
antes de qualquer leitura via constante `LIMITE_BYTES = 50 MB`, retornando `ErroArquivo::TamanhoExcedido`
se excedido — exibido como modal de erro pela UI.
**Esforço estimado:** Baixo (30min) — *concluído*
**Impacto:** Médio — evita travamentos inesperados
---
## Média Prioridade ## Média Prioridade
@@ -59,43 +25,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
--- ---
### F-04 — Busca por Número na Tela de Resultado (Util)
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
- está **faltante** (aparece na lista de faltantes)
- está **duplicada** (aparece na lista de duplicatas)
- está **presente** (está nas notas importadas)
- **não encontrada** (fora do intervalo conhecido)
**Escopo técnico:**
- Campo `filtro_numero: String` no `App`
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
- Banner de resultado no topo da tela
**Esforço estimado:** Médio (23h)
**Impacto:** Médio — uso diário em auditorias pontuais
---
### F-05 — Recarregar Arquivo Sem Reconfigurar (Implementado)
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
**Escopo técnico:**
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
- Botão na barra de ações da tela `resultado.rs`
**Esforço estimado:** Baixo (1h)
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
---
### F-06 — Auto-detecção de Delimitador CSV (Util) ### F-06 — Auto-detecção de Delimitador CSV (Util)
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam. **Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
@@ -152,11 +81,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
| ID | Feature | Esforço | Impacto | Prioridade | Status | | ID | Feature | Esforço | Impacto | Prioridade | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média | | F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
| F-04 | Busca por número no resultado | Médio | Médio | Média |
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média | | F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa | | F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
-353
View File
@@ -1,353 +0,0 @@
# FIX — Salvar Layout no Banco de Dados
**Data:** 04/03/2026
**Versão do PRD:** 1.7
**Status:** Pendente de Implementação
---
## Sumário dos Problemas
| # | Severidade | Arquivo(s) | Descrição |
|---|-----------|------------|-----------|
| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado |
| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations |
| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa |
| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco |
| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI |
| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas |
| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` |
| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite |
| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout |
---
## Problema 1 (Crítico) — `migration_v3` sem transação atômica
### Local
`src/infrastructure/sqlite/migrations.rs` — função `migration_v3`
### Situação Atual
```rust
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
)?;
Ok(())
}
```
`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial:
- `indice_documento_tipo` existe, `pos_documento_tipo` não existe.
- A versão no banco não é atualizada (o erro propaga antes).
- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"`**o app nunca mais inicializa sem intervenção manual**.
### Correção Necessária
Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**:
```rust
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
UPDATE schema_version SET versao = 3;
COMMIT;
")?;
Ok(())
}
```
> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`.
---
## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations
### Local
`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations`
### Situação Atual
O fluxo atual é:
1. Executa `migration_v1(conn)?`
2. Executa `migration_v2(conn)?`
3. Executa `migration_v3(conn)?`
4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...`
Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura.
### Correção Necessária
Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas.
Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão:
```rust
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE TABLE IF NOT EXISTS layouts ( ... );
INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1);
COMMIT;
")?;
Ok(())
}
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
-- renomeia duplicatas existentes se houver
UPDATE schema_version SET versao = 2;
COMMIT;
")?;
Ok(())
}
```
---
## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado`
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout`
### Situação Atual
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
```
Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria:
- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado"
- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado"
- `"attempt to write a readonly database"` → exibido como "JSON malformado"
### Correção Necessária
1. Adicionar variante própria em `domain/errors.rs`:
```rust
pub enum ErroLayout {
// ... variantes existentes ...
ErroBanco(String), // erros genéricos de I/O do banco
NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE)
}
```
2. No use case, inspecionar o erro antes de mapear:
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante
} else {
ErroLayout::ErroBanco(msg)
}
})?;
```
3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`.
---
## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco
### Local
`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV)
### Situação Atual
O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle.
### Correção Necessária
Normalizar o delimitador para um token legível antes de salvar:
```rust
// Ao salvar:
let delim_str = match config.delimitador {
'\t' => "tab".to_string(),
c => c.to_string(),
};
// Ao ler:
let delimitador = match delim_str.as_str() {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
};
```
> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts.
---
## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout`
### Situação Atual
A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option<String>`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto.
### Correção Necessária
Expandir a validação no use case `salvar_layout`:
```rust
match layout {
Layout::Csv { config, .. } => {
// indice_numero e indice_serie são usize — sempre válidos se presentes
// nenhuma validação adicional necessária aqui
}
Layout::Xlsx { config, .. } => {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_numero".to_string()));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
}
```
---
## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)`
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `listar`
### Situação Atual
O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação.
### Correção Necessária
Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`:
```rust
// Em vez de:
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
// Usar:
let id: i64 = row.get("id")?;
let nome: String = row.get("nome")?;
```
O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível.
---
## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada
### Local
`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs`
### Situação Atual
No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima).
### Correção Necessária
No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID):
```rust
// Verificar conflito excluindo o próprio registro
if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? {
return Err(ErroLayout::NomeConflitante);
}
```
Adicionar função no repository:
```rust
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option<i64>) -> Result<bool> {
match id {
Some(id) => {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
params![nome, id],
|row| row.get(0),
)?;
Ok(count > 0)
}
None => layout_repository::existe_nome(conn, nome),
}
}
```
---
## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco
### Local
`src/infrastructure/sqlite/connection.rs`
### Situação Atual
```rust
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* trata como banco corrompido */ }
}
```
`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente.
### Correção Necessária
```rust
match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* banco corrompido */ }
}
```
`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`.
---
## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar`
### Situação Atual
O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco.
### Correção Necessária
Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE:
```sql
-- UPDATE CSV: zerar campos XLSX
UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4,
indice_numero = ?5, indice_serie = ?6, indice_valor = ?7,
indice_data = ?8, indice_documento_tipo = ?9,
-- zerar campos XLSX:
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10
```
```sql
-- UPDATE XLSX: zerar campos CSV
UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
-- zerar campos CSV:
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
indice_data = NULL, indice_documento_tipo = NULL
WHERE id = ?8
```
---
## Ordem de Implementação Recomendada
1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro.
2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado.
3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos.
4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3.
5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash.
6. **Problema 6** (índices posicionais) — Refactor preventivo.
7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade.
8. **Problema 9** (zerar campos opostos) — Limpeza defensiva.
+41 -8
View File
@@ -5,6 +5,17 @@ use crate::domain::{
use crate::infrastructure::sqlite::layout_repository; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; use rusqlite::Connection;
/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome
/// de erros genéricos de banco.
fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante(nome.to_string())
} else {
ErroLayout::ErroBanco(msg)
}
}
/// Salva um layout no banco de dados. /// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome. /// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
@@ -14,19 +25,41 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayo
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string())); return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
} }
// Validar campos XLSX obrigatórios
if let Layout::Xlsx { config, .. } = layout {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
// Verificar conflito de nome com outro layout (excluindo o próprio)
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
.and_then(|conflito| {
if conflito {
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
} else {
Ok(())
}
})?;
layout_repository::atualizar(conn, layout) layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id) Ok(id)
} else { } else {
let nome = layout.nome().to_string(); let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome) let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
return Err(ErroLayout::NomeConflitante(nome)); return Err(ErroLayout::NomeConflitante(nome));
} }
layout_repository::salvar(conn, layout) layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
} }
} }
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome // Verificar conflito de nome
let nome_atual = layout.nome().to_string(); let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual) let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
if sobrescrever_se_existir { if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever // Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn) let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
let id_existente = layouts_existentes let id_existente = layouts_existentes
.iter() .iter()
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *i = Some(id), Layout::Xlsx { id: i, .. } => *i = Some(id),
} }
layout_repository::atualizar(conn, &layout) layout_repository::atualizar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| mapear_erro_banco(e, &nome_atual))?;
return Ok(id); return Ok(id);
} }
} else { } else {
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // Inserir novo
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string())) layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
} }
+2
View File
@@ -24,6 +24,8 @@ pub enum ErroLayout {
JsonMalformado(String), JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")] #[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String), NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
} }
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
+14 -4
View File
@@ -62,6 +62,8 @@ Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
`config.db.bak` e segue para a criação de um banco novo. `config.db.bak` e segue para a criação de um banco novo.
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado). 4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8).
--- ---
## migrations.rs ## migrations.rs
@@ -88,7 +90,11 @@ Fluxo:
- `versao_atual < 1``migration_v1` - `versao_atual < 1``migration_v1`
- `versao_atual < 2``migration_v2` - `versao_atual < 2``migration_v2`
- `versao_atual < 3``migration_v3` - `versao_atual < 3``migration_v3`
4. Persiste a nova versão (`INSERT` se era `0`, `UPDATE` caso contrário). 4. Persiste a nova versão na tabela `schema_version`:
- `versao_atual == 0``INSERT INTO schema_version (versao) VALUES (3)`
- `versao_atual > 0 && versao_atual < 3``UPDATE schema_version SET versao = 3`
> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2).
### Histórico de migrations ### Histórico de migrations
@@ -149,8 +155,10 @@ Insere um novo layout e retorna o `rowid` gerado.
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
`id` for `None`. `id` for `None`.
- `Layout::Csv` → atualiza apenas as colunas CSV. - `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
- `Layout::Xlsx` → atualiza apenas as colunas XLSX. - `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco.
> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9).
#### `listar(conn) -> Result<Vec<Layout>>` #### `listar(conn) -> Result<Vec<Layout>>`
@@ -161,6 +169,8 @@ Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos. Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6).
#### `excluir(conn, id) -> Result<()>` #### `excluir(conn, id) -> Result<()>`
Remove o registro com o `id` informado via `DELETE`. Remove o registro com o `id` informado via `DELETE`.
@@ -187,7 +197,7 @@ aplicar_migrations(conn)
├─ cria schema_version se necessário ├─ cria schema_version se necessário
├─ lê versão atual ├─ lê versão atual
├─ executa migrations pendentes (v1 → v2 → v3) ├─ executa migrations pendentes (v1 → v2 → v3)
└─ grava versão final └─ grava versão final (INSERT se versao=0, UPDATE se versao<3)
``` ```
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`. Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
if caminho.exists() { if caminho.exists() {
match Connection::open(caminho) { match Connection::open(caminho) {
Ok(conn) => { Ok(conn) => {
// Testar se o banco funciona com uma query simples // Testar integridade estrutural do banco
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(_e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
+61 -22
View File
@@ -1,6 +1,22 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result}; use rusqlite::{params, Connection, Result};
/// Converte o char delimitador para string legível no banco.
fn delim_para_str(c: char) -> String {
match c {
'\t' => "tab".to_string(),
c => c.to_string(),
}
}
/// Converte a string armazenada no banco de volta para char delimitador.
fn str_para_delim(s: &str) -> char {
match s {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
}
}
/// Salva um layout no banco. Retorna o id gerado. /// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> { pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout { match layout {
@@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
} }
/// Atualiza um layout existente no banco. /// Atualiza um layout existente no banco.
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout let id = layout
.id() .id()
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9 indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10", WHERE id = ?10",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute( conn.execute(
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7 pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
indice_data = NULL, indice_documento_tipo = NULL
WHERE id = ?8", WHERE id = ?8",
params![ params![
nome, nome,
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let layouts: Result<Vec<Layout>> = stmt let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| { .query_map([], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get("id")?;
let nome: String = row.get(1)?; let nome: String = row.get("nome")?;
let tipo: String = row.get(2)?; let tipo: String = row.get("tipo")?;
if tipo == "csv" { if tipo == "csv" {
let delim_str: String = row.get(3)?; let delim_str: String = row.get("delimitador")?;
let delimitador = delim_str.chars().next().unwrap_or(';'); let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv { Ok(Layout::Csv {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutCsv { config: LayoutCsv {
delimitador, delimitador,
encoding: row.get(4)?, encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>(5)? as usize, linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>(6)? as usize, indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>(7)? as usize, indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize), indice_valor: row
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize), .get::<_, Option<i64>>("indice_valor")?
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize), .map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>("indice_data")?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>("indice_documento_tipo")?
.map(|v| v as usize),
}, },
}) })
} else { } else {
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutXlsx { config: LayoutXlsx {
aba: row.get(10)?, aba: row.get("aba")?,
pos_numero: row.get(11)?, pos_numero: row.get("pos_numero")?,
pos_serie: row.get(12)?, pos_serie: row.get("pos_serie")?,
pos_valor: row.get(13)?, pos_valor: row.get("pos_valor")?,
pos_data: row.get(14)?, pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get(16)?, pos_documento_tipo: row.get("pos_documento_tipo")?,
}, },
}) })
} }
@@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
)?; )?;
Ok(count > 0) Ok(count > 0)
} }
/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado.
/// Usado para validar conflito de nome ao renomear um layout existente.
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
params![nome, id],
|row| row.get(0),
)?;
Ok(count > 0)
}
+15 -21
View File
@@ -1,8 +1,5 @@
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco /// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente. /// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> { pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
@@ -29,26 +26,17 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
migration_v3(conn)?; migration_v3(conn)?;
} }
if versao_atual == 0 {
conn.execute(
"INSERT INTO schema_version (versao) VALUES (?1);",
[VERSAO_SCHEMA_ATUAL],
)?;
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
conn.execute(
"UPDATE schema_version SET versao = ?1;",
[VERSAO_SCHEMA_ATUAL],
)?;
}
Ok(()) Ok(())
} }
/// Migration v3: adicionar colunas de tipo de documento nos layouts. /// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> { fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; "BEGIN;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
UPDATE schema_version SET versao = 3;
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
@@ -59,12 +47,15 @@ fn migration_v3(conn: &Connection) -> Result<()> {
/// em bancos que já possuem nomes repetidos. /// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> { fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"UPDATE layouts "BEGIN;
UPDATE layouts
SET nome = nome || ' (' || id || ')' SET nome = nome || ' (' || id || ')'
WHERE id NOT IN ( WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome SELECT MIN(id) FROM layouts GROUP BY nome
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);", CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
@@ -72,7 +63,8 @@ fn migration_v2(conn: &Connection) -> Result<()> {
/// Migration v1: criar tabela de layouts. /// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> { fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts ( "BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
@@ -92,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
pos_serie TEXT, pos_serie TEXT,
pos_valor TEXT, pos_valor TEXT,
pos_data TEXT pos_data TEXT
);", );
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
+17 -3
View File
@@ -81,11 +81,19 @@ literais fora de `theme.rs`.**
**Botões:** **Botões:**
- `t::btn_primary` — azul sólido, ação principal - `t::btn_primary` — azul sólido, ação principal
- `t::btn_secondary` — SURFACE_2, ação secundária - `t::btn_secondary` — SURFACE_2 com borda `TEXT_SECONDARY` 55%, ação secundária
- `t::btn_ghost` — transparente com borda, ação terciária - `t::btn_ghost` — transparente com borda `TEXT_SECONDARY` 55%, ação terciária
- `t::btn_danger` — vermelho semitransparente, exclusão - `t::btn_danger` — vermelho semitransparente, exclusão
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX - `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação - `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
- `t::btn_breadcrumb_visitado` — botão sem fundo para passos anteriores do breadcrumb
### Contraste de estados de botão
`btn_secondary` e `btn_ghost` usam borda visível (`TEXT_SECONDARY` a 55% de opacidade)
no estado normal para se destacar de containers com fundo `SURFACE_2`. O estado
`Disabled` reduz a borda para 20% de opacidade, criando contraste claro entre ativo e
inativo. **Nunca remova a borda desses estilos** — sem ela os botões se perdem no fundo.
**Inputs:** **Inputs:**
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY - `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
@@ -214,7 +222,7 @@ Chamado em `app.rs` quando `self.modal.is_some()`.
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...). Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal. - Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
- A altura da área de scroll está fixada em `Fixed(160.0)`intencional. - A altura da área de scroll está fixada em `Fixed(200.0)`acomoda 5 linhas + cabeçalho + scrollbar horizontal.
- Scroll horizontal via `scrollable::Direction::Horizontal`. - Scroll horizontal via `scrollable::Direction::Horizontal`.
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)` ### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
@@ -244,6 +252,12 @@ diretamente de dentro de uma tela.**
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
exceto `GerenciandoLayouts` e `Analisando`. exceto `GerenciandoLayouts` e `Analisando`.
### Comportamento de navegação do breadcrumb
- **Passo ativo** — destaque azul com fundo semitransparente (`PRIMARY` a 15%).
- **Passos anteriores** — `button` com `t::btn_breadcrumb_visitado` e `on_press` com a mensagem de navegação correspondente (`Message::IrParaImportacao` para passo 1, `Message::IrParaConfiguracaoColunas` para passo 2). O hover revela fundo sutil + borda, sinalizando interatividade.
- **Passos futuros** — `container` com texto `TEXT_MUTED`, sem interação (não é possível pular à frente).
--- ---
## 7. Adicionando uma nova tela ## 7. Adicionando uma nova tela
+26 -14
View File
@@ -14,7 +14,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 crate::ui::theme as t;
use iced::widget::{column, container, text, Row}; use iced::widget::{button, column, container, text, Row};
use iced::{Alignment, Element, Length, Task}; use iced::{Alignment, Element, Length, Task};
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
@@ -614,9 +614,11 @@ impl App {
let modal = self.modal.take(); let modal = self.modal.take();
if let Some(estado) = modal { if let Some(estado) = modal {
match estado { match estado {
EstadoModal::Confirmacao { acao, .. } EstadoModal::Confirmacao { acao, .. } => {
| EstadoModal::InputTexto { acao, .. } => { return self.executar_acao_modal(acao, None);
return self.executar_acao_modal(acao); }
EstadoModal::InputTexto { acao, texto, .. } => {
return self.executar_acao_modal(acao, Some(texto));
} }
_ => {} _ => {}
} }
@@ -786,6 +788,12 @@ impl App {
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 msg_nav = match n {
1 => Some(Message::IrParaImportacao),
2 => Some(Message::IrParaConfiguracaoColunas),
_ => None,
};
let elem: Element<'_, Message> = if *n == passo_ativo { let elem: Element<'_, Message> = if *n == passo_ativo {
container(text(*label).size(13).color(t::PRIMARY)) container(text(*label).size(13).color(t::PRIMARY))
.padding([4, 10]) .padding([4, 10])
@@ -796,9 +804,19 @@ impl App {
}) })
.into() .into()
} else if *n < passo_ativo { } else if *n < passo_ativo {
text(*label).size(13).color(t::TEXT_SECONDARY).into() // Passo já visitado — botão clicável
let mut btn = button(text(*label).size(13))
.padding([4, 10])
.style(t::btn_breadcrumb_visitado);
if let Some(msg) = msg_nav {
btn = btn.on_press(msg);
}
btn.into()
} else { } else {
text(*label).size(13).color(t::TEXT_MUTED).into() // Passo futuro — ainda não disponível
container(text(*label).size(13).color(t::TEXT_MUTED))
.padding([4, 10])
.into()
}; };
items.push(elem); items.push(elem);
if i < passos.len() - 1 { if i < passos.len() - 1 {
@@ -991,7 +1009,7 @@ impl App {
} }
} }
fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task<Message> { fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option<String>) -> Task<Message> {
match acao { match acao {
AcaoModal::ConfirmarExpansaoFaltantes => { AcaoModal::ConfirmarExpansaoFaltantes => {
self.update(Message::ConfirmarExpansaoFaltantes) self.update(Message::ConfirmarExpansaoFaltantes)
@@ -1009,13 +1027,7 @@ impl App {
Task::none() Task::none()
} }
AcaoModal::SalvarLayoutConfig => { AcaoModal::SalvarLayoutConfig => {
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal { let nome = texto_input.unwrap_or_default().trim().to_string();
texto.clone()
} else {
String::new()
};
self.modal = None;
let nome = texto.trim().to_string();
if nome.is_empty() { if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio."); self.exibir_erro("O nome do layout não pode ser vazio.");
return Task::none(); return Task::none();
+1 -1
View File
@@ -82,7 +82,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.direction(scrollable::Direction::Horizontal( .direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(), scrollable::Scrollbar::default(),
)) ))
.height(Length::Fixed(160.0)), .height(Length::Fixed(200.0)),
) )
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE.into()), background: Some(t::SURFACE.into()),
+51 -5
View File
@@ -288,8 +288,12 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
background: Some(SURFACE_2.into()), background: Some(SURFACE_2.into()),
text_color: TEXT, text_color: TEXT,
border: Border { border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(), radius: 6.0.into(),
..Default::default()
}, },
..Default::default() ..Default::default()
}; };
@@ -304,6 +308,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
} }
.into(), .into(),
), ),
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base ..base
}, },
button::Status::Disabled => button::Style { button::Status::Disabled => button::Style {
@@ -315,6 +323,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
.into(), .into(),
), ),
text_color: Color { a: 0.4, ..TEXT }, text_color: Color { a: 0.4, ..TEXT },
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base ..base
}, },
_ => { _ => {
@@ -324,13 +336,16 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Botão ghost (sem fundo, borda sutil) /// Botão ghost (sem fundo, borda visível)
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style { pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style { let base = button::Style {
background: Some(Color::TRANSPARENT.into()), background: Some(Color::TRANSPARENT.into()),
text_color: TEXT_SECONDARY, text_color: TEXT,
border: Border { border: Border {
color: BORDER, color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0, width: 1.0,
radius: 6.0.into(), radius: 6.0.into(),
}, },
@@ -340,12 +355,16 @@ pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
button::Status::Hovered => button::Style { button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..PRIMARY }.into()), background: Some(Color { a: 0.08, ..PRIMARY }.into()),
text_color: TEXT, text_color: TEXT,
border: Border {
color: PRIMARY,
..base.border
},
..base ..base
}, },
button::Status::Disabled => button::Style { button::Status::Disabled => button::Style {
text_color: TEXT_MUTED, text_color: TEXT_MUTED,
border: Border { border: Border {
color: Color { a: 0.3, ..BORDER }, color: Color { a: 0.2, ..BORDER },
..base.border ..base.border
}, },
..base ..base
@@ -381,6 +400,33 @@ pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Botão de passo anterior no breadcrumb (clicável, mas sem destaque)
pub fn btn_breadcrumb_visitado(_theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: None,
text_color: TEXT_SECONDARY,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..TEXT }.into()),
text_color: TEXT,
border: Border {
color: Color { a: 0.15, ..TEXT },
width: 1.0,
radius: 4.0.into(),
},
..base
},
_ => base,
}
}
/// Botão de aba selecionada (tela selecionar_aba) /// Botão de aba selecionada (tela selecionar_aba)
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style { pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style { let base = button::Style {