update
This commit is contained in:
@@ -7,7 +7,11 @@
|
|||||||
"Bash(npm install expo-camera)",
|
"Bash(npm install expo-camera)",
|
||||||
"Bash(npm install expo-camera --legacy-peer-deps)",
|
"Bash(npm install expo-camera --legacy-peer-deps)",
|
||||||
"Bash(ls /var/home/felipecn/DEV/MeowSpool/mobile/app/\\\\\\(app\\\\\\)/)",
|
"Bash(ls /var/home/felipecn/DEV/MeowSpool/mobile/app/\\\\\\(app\\\\\\)/)",
|
||||||
"Bash(java -version)"
|
"Bash(java -version)",
|
||||||
|
"Bash(mise exec:*)",
|
||||||
|
"Bash(grep -rE '\"\"@adapters\"\"' tsconfig*.json babel.config.*)",
|
||||||
|
"Bash(grep -n \"pub struct Line\\\\|pub fn add_shape\\\\|fn add_polygon\\\\|fn add_rect\\\\|PdfLayerReference\" /home/felipecn/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/printpdf-0.7.0/src/*.rs)",
|
||||||
|
"mcp__paper__get_basic_info"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200" style="width: 38px; height: 38px; flex-shrink: 0">
|
||||||
|
<g stroke="#C9C1B0" stroke-width="10" fill="none">
|
||||||
|
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49" />
|
||||||
|
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80" />
|
||||||
|
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11" />
|
||||||
|
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
Generated
+77
@@ -348,6 +348,17 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1555,6 +1566,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linked-hash-map"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1585,6 +1602,23 @@ dependencies = [
|
|||||||
"imgref",
|
"imgref",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lopdf"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"flate2",
|
||||||
|
"itoa",
|
||||||
|
"linked-hash-map",
|
||||||
|
"log",
|
||||||
|
"md5",
|
||||||
|
"pom",
|
||||||
|
"time",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1626,6 +1660,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -1646,6 +1686,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
|
"printpdf",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1849,6 +1890,15 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "owned_ttf_parser"
|
||||||
|
version = "0.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4"
|
||||||
|
dependencies = [
|
||||||
|
"ttf-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1984,6 +2034,15 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pom"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b"
|
||||||
|
dependencies = [
|
||||||
|
"bstr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2028,6 +2087,18 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "printpdf"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"lopdf",
|
||||||
|
"owned_ttf_parser",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-error"
|
name = "proc-macro-error"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3444,6 +3515,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ttf-parser"
|
||||||
|
version = "0.19.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ qrcode = "0.14"
|
|||||||
image = "0.25"
|
image = "0.25"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# PDF generation
|
||||||
|
printpdf = "0.7"
|
||||||
|
|
||||||
# Async trait
|
# Async trait
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
# MeowSpool Backend — TODO
|
||||||
|
|
||||||
|
> Última atualização: 14/03/2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crítico — Funcionalidades Quebradas
|
||||||
|
|
||||||
|
### Auth: Email e Reset de Senha
|
||||||
|
|
||||||
|
Todos os três handlers abaixo retornam `200 OK` sem executar nenhuma lógica real.
|
||||||
|
Dependem de infraestrutura compartilhada que ainda não existe (ver "Bloqueadores").
|
||||||
|
|
||||||
|
- [ ] **`forgot_password_handler`** (`src/adapters/inbound/auth_handler.rs:131`)
|
||||||
|
- Gerar token de reset seguro (UUID v4 ou CSPRNG)
|
||||||
|
- Salvar na tabela `password_reset_tokens` (ver migration abaixo)
|
||||||
|
- Enviar email com link de reset via serviço de email
|
||||||
|
- Token deve ter TTL de 1h e ser invalidado após uso
|
||||||
|
|
||||||
|
- [ ] **`verify_email_handler`** (`src/adapters/inbound/auth_handler.rs:138`)
|
||||||
|
- Validar token recebido contra tabela `email_verification_tokens`
|
||||||
|
- Chamar `user_repo.verify_email(user_id)` (método já implementado no repo)
|
||||||
|
- Marcar token como usado / deletar após validação
|
||||||
|
- Retornar `400 Bad Request` para token inválido ou expirado
|
||||||
|
|
||||||
|
- [ ] **`reset_password_handler`** (`src/adapters/inbound/auth_handler.rs:143`)
|
||||||
|
- Validar token recebido contra tabela `password_reset_tokens`
|
||||||
|
- Verificar TTL e se já foi usado
|
||||||
|
- Hash da nova senha com Argon2id
|
||||||
|
- Atualizar `password_hash` via `user_repo.update()`
|
||||||
|
- Invalidar token após uso
|
||||||
|
|
||||||
|
### Bloqueadores das features de Auth acima
|
||||||
|
|
||||||
|
- [ ] **Migration: `password_reset_tokens`**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX ON password_reset_tokens (token);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Migration: `email_verification_tokens`**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE email_verification_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX ON email_verification_tokens (token);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Serviço de email** (`src/infrastructure/email_service.rs` — novo arquivo)
|
||||||
|
- Adicionar dependência: `lettre` (SMTP) ou integração com API (Resend, SendGrid, Mailgun)
|
||||||
|
- Adicionar vars de ambiente no `config.rs`: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`
|
||||||
|
- Implementar `send_password_reset_email(to, reset_link)`
|
||||||
|
- Implementar `send_verification_email(to, code)`
|
||||||
|
|
||||||
|
### Perfil de Usuário
|
||||||
|
|
||||||
|
- [ ] **`update_me_handler`** (`src/adapters/inbound/user_handler.rs:64`)
|
||||||
|
- Handler valida formato de email mas não persiste nada
|
||||||
|
- Implementar chamada a `user_repo.update()` com os campos recebidos
|
||||||
|
- Considerar campos permitidos: `name` (se adicionado ao domínio), `email`
|
||||||
|
- Revalidar unicidade de email antes de atualizar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Médio — Segurança
|
||||||
|
|
||||||
|
- [ ] **Google OAuth: validar `aud` claim** (`src/adapters/inbound/auth_handler.rs:159`)
|
||||||
|
- O parâmetro `_client_id` é recebido mas ignorado
|
||||||
|
- Após obter o payload do tokeninfo do Google, verificar que `aud == GOOGLE_CLIENT_ID`
|
||||||
|
- Rejeitar com `401 Unauthorized` se o `aud` não corresponder
|
||||||
|
- Sem isso, qualquer Google token válido de qualquer app é aceito
|
||||||
|
|
||||||
|
- [ ] **Logout: invalidar refresh token** (`src/adapters/inbound/auth_handler.rs:121`)
|
||||||
|
- Atualmente retorna `204 No Content` sem nenhuma ação
|
||||||
|
- Opção A (simples): tabela `revoked_tokens` no PostgreSQL com TTL via `expires_at`
|
||||||
|
- Opção B (recomendada): Redis com `SET revoked:{jti} 1 EX <ttl>`
|
||||||
|
- Middleware de auth deve checar blocklist ao validar tokens
|
||||||
|
- Adicionar campo `jti` (JWT ID) aos tokens para identificação única
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baixo — Qualidade e Completude
|
||||||
|
|
||||||
|
- [ ] **Sort de filamentos não aplicado** (`src/adapters/outbound/postgres_filament_repo.rs`)
|
||||||
|
- `SortOrder` enum está definido em `ports/filament_repository.rs`
|
||||||
|
- SQL em `list()` sempre usa `ORDER BY created_at DESC`, ignorando o campo `sort`
|
||||||
|
- `filament_handler.rs:203` tem `// TODO: parse sort string para SortOrder` — completar o parse
|
||||||
|
- Aplicar o `SortOrder` no SQL com match: `net_weight_g ASC`, `net_weight_g DESC`, `created_at DESC`
|
||||||
|
|
||||||
|
- [ ] **Paginação: retornar total de itens**
|
||||||
|
- `FilamentRepository::count()` está implementado no repo mas nunca é chamado
|
||||||
|
- `list_filaments_handler` não retorna metadados de paginação
|
||||||
|
- Adicionar ao response: `{ data: [...], total: N, page: N, per_page: N }`
|
||||||
|
- Chamar `repo.count(user_id, filter)` em paralelo com `repo.list()`
|
||||||
|
|
||||||
|
- [ ] **`GET /spool-presets/:id` — handler ausente**
|
||||||
|
- Rota está registrada no `router.rs`
|
||||||
|
- Nenhum handler correspondente existe em `spool_preset_handler.rs`
|
||||||
|
- Implementar `get_preset_handler` ou remover a rota do router
|
||||||
|
|
||||||
|
- [ ] **Testes**
|
||||||
|
- Sem nenhum teste unitário ou de integração no projeto
|
||||||
|
- Prioridade: `AuthService` (hash, JWT), `FilamentService` (cálculos de peso, conflito offline), handlers de auth
|
||||||
|
- Usar `sqlx::test` para testes de repositório com banco real em transação
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referência Rápida — Arquivos por Área
|
||||||
|
|
||||||
|
| Área | Arquivo |
|
||||||
|
| --------------------- | ------------------------------------------------- |
|
||||||
|
| Handlers de auth | `src/adapters/inbound/auth_handler.rs` |
|
||||||
|
| Handler de usuário | `src/adapters/inbound/user_handler.rs` |
|
||||||
|
| Handler de filamentos | `src/adapters/inbound/filament_handler.rs` |
|
||||||
|
| Handler de presets | `src/adapters/inbound/spool_preset_handler.rs` |
|
||||||
|
| Repo de usuário | `src/adapters/outbound/postgres_user_repo.rs` |
|
||||||
|
| Repo de filamentos | `src/adapters/outbound/postgres_filament_repo.rs` |
|
||||||
|
| Serviço de auth | `src/application/auth_service.rs` |
|
||||||
|
| Config / env vars | `src/config.rs` |
|
||||||
|
| Migrations | `backend/migrations/` |
|
||||||
+49
-2
@@ -24,6 +24,9 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
|
|||||||
| Validação | `validator` | 0.18 |
|
| Validação | `validator` | 0.18 |
|
||||||
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
||||||
| Geração QR Code | `qrcode` | 0.14 |
|
| Geração QR Code | `qrcode` | 0.14 |
|
||||||
|
| Geração de imagem| `image` | 0.25 |
|
||||||
|
| Encode Base64 | `base64` | 0.22 |
|
||||||
|
| Geração PDF | `printpdf` | 0.7 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,6 +141,7 @@ Todas as rotas são prefixadas com `/api/v1`.
|
|||||||
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
|
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
|
||||||
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
|
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
|
||||||
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
|
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
|
||||||
|
| GET | `/:id/label.pdf` | `export_label_pdf_handler`| Autenticado |
|
||||||
|
|
||||||
**Query params de listagem (`GET /filaments`):**
|
**Query params de listagem (`GET /filaments`):**
|
||||||
|
|
||||||
@@ -370,11 +374,36 @@ O backend implementa **last-write-wins com timestamp**:
|
|||||||
|
|
||||||
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
|
||||||
|
|
||||||
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30)
|
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
|
||||||
- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
|
- Retorna SVG com os campos selecionados embutidos
|
||||||
- `Content-Type: image/svg+xml`
|
- `Content-Type: image/svg+xml`
|
||||||
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
|
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
|
||||||
|
|
||||||
|
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
||||||
|
|
||||||
|
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
|
||||||
|
- Retorna PDF de página única com dimensões exatas em mm — ideal para impressoras de etiqueta (Brother, Dymo)
|
||||||
|
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code embutido como PNG luma
|
||||||
|
- `Content-Type: application/pdf`
|
||||||
|
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
|
||||||
|
|
||||||
|
### Parâmetro `fields` (SVG e PDF)
|
||||||
|
|
||||||
|
Controla quais elementos aparecem na etiqueta. Valor: string com itens separados por vírgula.
|
||||||
|
|
||||||
|
| Valor | Elemento |
|
||||||
|
| --------------- | ------------------------------- |
|
||||||
|
| `color` | Barra de cor lateral |
|
||||||
|
| `name` | Nome do modelo |
|
||||||
|
| `material_brand`| Texto "Marca · Material" |
|
||||||
|
| `net_weight` | Peso líquido disponível |
|
||||||
|
| `print_temp` | Temperatura de impressão |
|
||||||
|
| `qrcode` | QR Code com deep link |
|
||||||
|
|
||||||
|
Se `fields` for omitido, todos os elementos são incluídos.
|
||||||
|
|
||||||
|
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Segurança
|
## Segurança
|
||||||
@@ -390,6 +419,24 @@ O backend implementa **last-write-wins com timestamp**:
|
|||||||
|
|
||||||
## Mudanças Recentes (14/03/2026)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
|
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
|
||||||
|
|
||||||
|
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
|
||||||
|
- **Service**: `FilamentService::generate_label_pdf` em `filament_service.rs`
|
||||||
|
- Layout: fundo `#1E1B18`, barra de cor (filament.color_hex), texto Helvetica builtin, QR Code PNG luma embutido
|
||||||
|
- Página com dimensões exatas em mm via `printpdf::Mm`
|
||||||
|
- Usa `Polygon` + `PaintMode::Fill` para retângulos (API do printpdf 0.7)
|
||||||
|
- QR Code embutido via `ImageXObject` com pixels luma brutos — sem depender do feature `image` do printpdf
|
||||||
|
- **Handler**: `export_label_pdf_handler` em `filament_handler.rs`
|
||||||
|
- **Rota**: `GET /api/v1/filaments/:id/label.pdf`
|
||||||
|
|
||||||
|
### ✅ Controle de Campos na Etiqueta (`fields` query param)
|
||||||
|
|
||||||
|
- **Struct**: `LabelFields` em `filament_service.rs` — parseia string CSV de campos ativos
|
||||||
|
- **Aplicado em**: `generate_label_svg` e `generate_label_pdf`
|
||||||
|
- **LabelQuery**: adicionado campo `fields: Option<String>` em `filament_handler.rs`
|
||||||
|
- Permite que o cliente selecione quais elementos aparecem na etiqueta (cor, nome, material, peso, temp, QR)
|
||||||
|
|
||||||
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
|
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
|
||||||
|
|
||||||
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
|
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ info:
|
|||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
url: http://0.0.0.0:8080/api/v1/auth/login
|
url: https://meowspool.felipecncloud.com/api/v1/auth/login
|
||||||
body:
|
body:
|
||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ pub struct LabelQuery {
|
|||||||
pub width_mm: u32,
|
pub width_mm: u32,
|
||||||
#[serde(default = "default_height_mm")]
|
#[serde(default = "default_height_mm")]
|
||||||
pub height_mm: u32,
|
pub height_mm: u32,
|
||||||
|
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
|
||||||
|
/// Se ausente, inclui todos.
|
||||||
|
pub fields: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_width_mm() -> u32 { 50 }
|
fn default_width_mm() -> u32 { 50 }
|
||||||
@@ -286,6 +289,30 @@ pub async fn get_qrcode_handler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn export_label_pdf_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<CurrentUser>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<LabelQuery>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let pdf = state
|
||||||
|
.filament_service
|
||||||
|
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let filename = format!("meowspool-label-{id}.pdf");
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/pdf".to_string()),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pdf,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn export_label_handler(
|
pub async fn export_label_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(user): Extension<CurrentUser>,
|
Extension(user): Extension<CurrentUser>,
|
||||||
@@ -294,7 +321,7 @@ pub async fn export_label_handler(
|
|||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let svg = state
|
let svg = state
|
||||||
.filament_service
|
.filament_service
|
||||||
.generate_label_svg(id, user.id, query.width_mm, query.height_mm)
|
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let filename = format!("meowspool-label-{id}.svg");
|
let filename = format!("meowspool-label-{id}.svg");
|
||||||
|
|||||||
@@ -65,6 +65,41 @@ pub struct FilamentService {
|
|||||||
preset_repo: Arc<dyn SpoolPresetRepository>,
|
preset_repo: Arc<dyn SpoolPresetRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Campos de conteúdo a incluir na etiqueta.
|
||||||
|
pub struct LabelFields {
|
||||||
|
pub color: bool,
|
||||||
|
pub name: bool,
|
||||||
|
pub material_brand: bool,
|
||||||
|
pub net_weight: bool,
|
||||||
|
pub print_temp: bool,
|
||||||
|
pub qrcode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelFields {
|
||||||
|
/// Parseia uma string de campos separados por vírgula.
|
||||||
|
/// Se `None`, habilita todos.
|
||||||
|
pub fn from_str(s: Option<&str>) -> Self {
|
||||||
|
match s {
|
||||||
|
None => Self::all(),
|
||||||
|
Some(s) => {
|
||||||
|
let parts: Vec<&str> = s.split(',').map(str::trim).collect();
|
||||||
|
Self {
|
||||||
|
color: parts.contains(&"color"),
|
||||||
|
name: parts.contains(&"name"),
|
||||||
|
material_brand: parts.contains(&"material_brand"),
|
||||||
|
net_weight: parts.contains(&"net_weight"),
|
||||||
|
print_temp: parts.contains(&"print_temp"),
|
||||||
|
qrcode: parts.contains(&"qrcode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all() -> Self {
|
||||||
|
Self { color: true, name: true, material_brand: true, net_weight: true, print_temp: true, qrcode: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FilamentService {
|
impl FilamentService {
|
||||||
pub fn new(repo: Arc<dyn FilamentRepository>, preset_repo: Arc<dyn SpoolPresetRepository>) -> Self {
|
pub fn new(repo: Arc<dyn FilamentRepository>, preset_repo: Arc<dyn SpoolPresetRepository>) -> Self {
|
||||||
Self { repo, preset_repo }
|
Self { repo, preset_repo }
|
||||||
@@ -255,7 +290,9 @@ impl FilamentService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
width_mm: u32,
|
width_mm: u32,
|
||||||
height_mm: u32,
|
height_mm: u32,
|
||||||
|
fields: Option<&str>,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
|
let f = LabelFields::from_str(fields);
|
||||||
let filament = self.get(id, user_id).await?;
|
let filament = self.get(id, user_id).await?;
|
||||||
|
|
||||||
// Gerar QR Code como PNG base64 para embutir no SVG
|
// Gerar QR Code como PNG base64 para embutir no SVG
|
||||||
@@ -274,37 +311,191 @@ impl FilamentService {
|
|||||||
let brand = &filament.brand;
|
let brand = &filament.brand;
|
||||||
let color = &filament.color_hex;
|
let color = &filament.color_hex;
|
||||||
|
|
||||||
|
let color_swatch = if f.color {
|
||||||
|
format!(r#" <rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>"#,
|
||||||
|
h = height_px - 12, color = color)
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let text_name = if f.name {
|
||||||
|
format!(r##" <text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>"##,
|
||||||
|
model_name = escape_xml(model_name))
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let text_meta = if f.material_brand {
|
||||||
|
format!(r##" <text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>"##,
|
||||||
|
brand = escape_xml(brand), material = escape_xml(&material))
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let text_weight = if f.net_weight {
|
||||||
|
format!(r##" <text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>"##,
|
||||||
|
net_weight = net_weight)
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let qr_element = if f.qrcode {
|
||||||
|
format!(r##" <image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}" xlink:href="data:image/png;base64,{qr_b64}"/>"##,
|
||||||
|
qr_x = width_px - qr_size - 4,
|
||||||
|
qr_y = (height_px - qr_size) / 2,
|
||||||
|
qr_size = qr_size,
|
||||||
|
qr_b64 = qr_b64)
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
let svg = format!(
|
let svg = format!(
|
||||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
|
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
|
||||||
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="4"/>
|
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="4"/>
|
||||||
<!-- Color swatch -->
|
{color_swatch}
|
||||||
<rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>
|
{text_name}
|
||||||
<!-- Text block -->
|
{text_meta}
|
||||||
<text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>
|
{text_weight}
|
||||||
<text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>
|
{qr_element}
|
||||||
<text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>
|
|
||||||
<!-- QR Code -->
|
|
||||||
<image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}"
|
|
||||||
xlink:href="data:image/png;base64,{qr_b64}"/>
|
|
||||||
</svg>"##,
|
</svg>"##,
|
||||||
width_px = width_px,
|
width_px = width_px,
|
||||||
height_px = height_px,
|
height_px = height_px,
|
||||||
h = height_px - 12,
|
|
||||||
color = color,
|
|
||||||
model_name = escape_xml(model_name),
|
|
||||||
brand = escape_xml(brand),
|
|
||||||
material = escape_xml(&material),
|
|
||||||
net_weight = net_weight,
|
|
||||||
qr_size = qr_size,
|
|
||||||
qr_x = width_px - qr_size - 4,
|
|
||||||
qr_y = (height_px - qr_size) / 2,
|
|
||||||
qr_b64 = qr_b64,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(svg)
|
Ok(svg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PDF Label
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn generate_label_pdf(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
width_mm: u32,
|
||||||
|
height_mm: u32,
|
||||||
|
fields: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>, AppError> {
|
||||||
|
let f = LabelFields::from_str(fields);
|
||||||
|
use printpdf::{
|
||||||
|
path::{PaintMode, WindingOrder},
|
||||||
|
ColorBits, ColorSpace, CurTransMat, Image, ImageTransform, ImageXObject, Mm, Point,
|
||||||
|
Polygon, Px, PdfDocument, BuiltinFont, Color, Rgb,
|
||||||
|
};
|
||||||
|
|
||||||
|
let filament = self.get(id, user_id).await?;
|
||||||
|
|
||||||
|
// QR Code como pixels luma — sem depender do feature image do printpdf
|
||||||
|
let qr_png = self.generate_qrcode_png(id, user_id).await?;
|
||||||
|
let qr_img = image::load_from_memory(&qr_png)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("QR decode: {e}")))?
|
||||||
|
.to_luma8();
|
||||||
|
let (qr_px_w, qr_px_h) = qr_img.dimensions();
|
||||||
|
let qr_raw = qr_img.into_raw();
|
||||||
|
|
||||||
|
let w = width_mm as f32;
|
||||||
|
let h = height_mm as f32;
|
||||||
|
|
||||||
|
let (doc, page1, layer1) = PdfDocument::new(
|
||||||
|
format!("MeowSpool — {}", filament.brand),
|
||||||
|
Mm(w),
|
||||||
|
Mm(h),
|
||||||
|
"Content",
|
||||||
|
);
|
||||||
|
let layer = doc.get_page(page1).get_layer(layer1);
|
||||||
|
|
||||||
|
// Helper de retângulo preenchido via Polygon
|
||||||
|
let filled_rect = |x: f32, y: f32, rw: f32, rh: f32| Polygon {
|
||||||
|
rings: vec![vec![
|
||||||
|
(Point::new(Mm(x), Mm(y)), false),
|
||||||
|
(Point::new(Mm(x + rw), Mm(y)), false),
|
||||||
|
(Point::new(Mm(x + rw), Mm(y + rh)), false),
|
||||||
|
(Point::new(Mm(x), Mm(y + rh)), false),
|
||||||
|
]],
|
||||||
|
mode: PaintMode::Fill,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fundo escuro ──────────────────────────────────────────
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None))); // #1E1B18
|
||||||
|
layer.add_polygon(filled_rect(0.0, 0.0, w, h));
|
||||||
|
|
||||||
|
// ── Barra de cor ─────────────────────────────────────────
|
||||||
|
if f.color {
|
||||||
|
let hex = filament.color_hex.trim_start_matches('#');
|
||||||
|
let cr = u8::from_str_radix(hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
|
let cg = u8::from_str_radix(hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
|
let cb = u8::from_str_radix(hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(cr, cg, cb, None)));
|
||||||
|
layer.add_polygon(filled_rect(0.0, 0.0, 3.5, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR Code ───────────────────────────────────────────────
|
||||||
|
if f.qrcode {
|
||||||
|
let qr_size_mm = h * 0.75;
|
||||||
|
let qr_x = w - qr_size_mm - 2.0;
|
||||||
|
let qr_y = (h - qr_size_mm) / 2.0;
|
||||||
|
let qr_dpi = qr_px_w as f32 * 25.4 / qr_size_mm;
|
||||||
|
|
||||||
|
let qr_xobj = ImageXObject {
|
||||||
|
width: Px(qr_px_w as usize),
|
||||||
|
height: Px(qr_px_h as usize),
|
||||||
|
color_space: ColorSpace::Greyscale,
|
||||||
|
bits_per_component: ColorBits::Bit8,
|
||||||
|
interpolate: true,
|
||||||
|
image_data: qr_raw,
|
||||||
|
image_filter: None,
|
||||||
|
clipping_bbox: None,
|
||||||
|
smask: None,
|
||||||
|
};
|
||||||
|
Image::from(qr_xobj).add_to_layer(
|
||||||
|
layer.clone(),
|
||||||
|
ImageTransform {
|
||||||
|
translate_x: Some(Mm(qr_x)),
|
||||||
|
translate_y: Some(Mm(qr_y)),
|
||||||
|
rotate: None,
|
||||||
|
scale_x: None,
|
||||||
|
scale_y: None,
|
||||||
|
dpi: Some(qr_dpi),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Texto ─────────────────────────────────────────────────
|
||||||
|
let needs_text = f.name || f.material_brand || f.net_weight;
|
||||||
|
if needs_text {
|
||||||
|
let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
||||||
|
let font = doc.add_builtin_font(BuiltinFont::Helvetica)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
|
||||||
|
|
||||||
|
let text_x = 5.0_f32;
|
||||||
|
|
||||||
|
if f.name {
|
||||||
|
let model_name = filament.model.as_deref().unwrap_or(&filament.brand);
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.961, 0.933, 0.863, None)));
|
||||||
|
layer.use_text(model_name, 9.0, Mm(text_x), Mm(h - 8.0), &font_bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.material_brand {
|
||||||
|
let meta = format!("{} · {}", filament.brand, filament.material);
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.788, 0.757, 0.690, None)));
|
||||||
|
layer.use_text(&meta, 7.0, Mm(text_x), Mm(h - 14.0), &font);
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.net_weight {
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(0.220, 0.737, 0.761, None)));
|
||||||
|
layer.use_text(
|
||||||
|
&format!("{:.0}g disponível", filament.net_weight_g),
|
||||||
|
8.0,
|
||||||
|
Mm(text_x),
|
||||||
|
Mm(h - 20.0),
|
||||||
|
&font_bold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = CurTransMat::Identity; // suprime unused import
|
||||||
|
|
||||||
|
// ── Serializar ────────────────────────────────────────────
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
doc.save(&mut std::io::BufWriter::new(std::io::Cursor::new(&mut buf)))
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("PDF save: {e}")))?;
|
||||||
|
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_xml(s: &str) -> String {
|
fn escape_xml(s: &str) -> String {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
|||||||
.route("/filaments/:id", delete(filament_handler::delete_filament_handler))
|
.route("/filaments/:id", delete(filament_handler::delete_filament_handler))
|
||||||
.route("/filaments/:id/qrcode", get(filament_handler::get_qrcode_handler))
|
.route("/filaments/:id/qrcode", get(filament_handler::get_qrcode_handler))
|
||||||
.route("/filaments/:id/label.svg", get(filament_handler::export_label_handler))
|
.route("/filaments/:id/label.svg", get(filament_handler::export_label_handler))
|
||||||
|
.route("/filaments/:id/label.pdf", get(filament_handler::export_label_pdf_handler))
|
||||||
// Spool Presets
|
// Spool Presets
|
||||||
.route("/spool-presets", get(spool_preset_handler::list_presets_handler))
|
.route("/spool-presets", get(spool_preset_handler::list_presets_handler))
|
||||||
.route("/spool-presets", post(spool_preset_handler::create_preset_handler))
|
.route("/spool-presets", post(spool_preset_handler::create_preset_handler))
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__paper__get_basic_info",
|
||||||
|
"mcp__paper__get_tree_summary",
|
||||||
|
"mcp__paper__get_screenshot",
|
||||||
|
"mcp__paper__get_children",
|
||||||
|
"mcp__paper__get_computed_styles",
|
||||||
|
"mcp__paper__get_jsx",
|
||||||
|
"mcp__paper__write_html",
|
||||||
|
"mcp__paper__create_artboard",
|
||||||
|
"mcp__paper__delete_nodes",
|
||||||
|
"mcp__paper__finish_working_on_nodes"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
# MeowSpool Mobile — TODO
|
||||||
|
|
||||||
|
> Última atualização: 14/03/2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crítico — Funcionalidades Quebradas
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- [ ] **Reset de senha não executado** (`app/(auth)/reset-password.tsx`)
|
||||||
|
- `onSubmit` apenas faz `console.log` e navega para `password-reset-done`
|
||||||
|
- `resetPasswordUseCase` já existe em `src/infrastructure/container.ts`
|
||||||
|
- Chamar `resetPasswordUseCase.execute({ token, newPassword })` no submit
|
||||||
|
- O `token` deve ser lido dos params de navegação (vindo do deep link do email)
|
||||||
|
- Exibir `Alert.alert` em caso de erro da API
|
||||||
|
|
||||||
|
- [ ] **Verificar email — sem resend** (`app/(auth)/verify-email.tsx`)
|
||||||
|
- Tela é puramente estática
|
||||||
|
- Adicionar botão "Reenviar código"
|
||||||
|
- Criar `ResendVerificationUseCase` ou reutilizar `forgotPasswordUseCase` conforme API
|
||||||
|
- Exibir feedback de sucesso/erro no reenvio
|
||||||
|
|
||||||
|
### Filtros de Inventário — Desconexão Store/Lista
|
||||||
|
|
||||||
|
- [ ] **`inventory.tsx` não consome o `activeFilter` do store** (`app/(app)/(tabs)/inventory.tsx`)
|
||||||
|
- `filters.tsx` salva corretamente em `filamentStore.setFilter()`
|
||||||
|
- `inventory.tsx` filtra o array local com estado interno, ignorando `activeFilter` por completo
|
||||||
|
- Refatorar: ler `activeFilter` do store e aplicar junto com a busca local
|
||||||
|
- Ou: ao fechar `filters.tsx`, disparar `listFilamentsUseCase.execute()` com os filtros ativos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Médio — Funcionalidades Incompletas
|
||||||
|
|
||||||
|
### Google OAuth
|
||||||
|
|
||||||
|
- [ ] **Login com Google é stub** (`app/(auth)/login.tsx`)
|
||||||
|
- Botão navega direto para `/(app)/(tabs)/home` sem nenhuma autenticação real
|
||||||
|
- Instalar e configurar `expo-auth-session` + `expo-web-browser`
|
||||||
|
- Obter `id_token` do Google via `AuthSession.startAsync`
|
||||||
|
- Chamar `googleLoginUseCase.execute({ idToken })` (use case já existe em `container.ts`)
|
||||||
|
- Configurar `GOOGLE_CLIENT_ID` em `.env` e `app.json`
|
||||||
|
|
||||||
|
### Exportar Etiqueta SVG
|
||||||
|
|
||||||
|
- [ ] **Botão de exportar é stub** (`app/(app)/filaments/[id]/label.tsx`)
|
||||||
|
- Botão "Exportar SVG" exibe apenas um `Alert.alert` informativo
|
||||||
|
- Chamar `GET /filaments/:id/label.svg?width_mm=W&height_mm=H` via `httpClient`
|
||||||
|
- Usar `expo-file-system` para salvar o SVG localmente
|
||||||
|
- Usar `expo-sharing` para abrir o sheet de compartilhamento nativo
|
||||||
|
- Passar `width_mm` e `height_mm` conforme o tamanho selecionado na UI
|
||||||
|
|
||||||
|
### QR Code — Copiar Link
|
||||||
|
|
||||||
|
- [ ] **"Copiar link" exibe Alert em vez de copiar** (`app/(app)/filaments/[id]/qrcode.tsx`)
|
||||||
|
- Instalar `expo-clipboard` (ou usar `@react-native-clipboard/clipboard`)
|
||||||
|
- Substituir o `Alert.alert` por `Clipboard.setStringAsync(deepLink)`
|
||||||
|
- Exibir feedback visual breve (toast ou mudança de ícone por 2s)
|
||||||
|
|
||||||
|
### Perfil — Trocar Email / Senha
|
||||||
|
|
||||||
|
- [ ] **Botões de edição sem navegação** (`app/(app)/(tabs)/profile.tsx`)
|
||||||
|
- Botões "Trocar email" e "Trocar senha" existem na UI mas não navegam para nenhuma tela
|
||||||
|
- Criar telas `app/(app)/profile/change-email.tsx` e `app/(app)/profile/change-password.tsx`
|
||||||
|
- Chamar `PUT /users/me` (quando backend implementar `update_me_handler`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baixo — UX / Qualidade
|
||||||
|
|
||||||
|
- [ ] **Sort de filamentos sem ação** (`app/(app)/(tabs)/inventory.tsx`)
|
||||||
|
- Botão de sort está renderizado mas não tem `onPress` com lógica
|
||||||
|
- Implementar seleção de ordem: `net_weight_asc`, `net_weight_desc`, `created_at_desc`
|
||||||
|
- Passar `sort` como parâmetro no `listFilamentsUseCase.execute()`
|
||||||
|
- Ou ordenar o array local sem nova chamada à API
|
||||||
|
|
||||||
|
- [ ] **Tipos `Dashboard.ts` usados em `HomeScreen`** (`app/(app)/(tabs)/home.tsx`)
|
||||||
|
- `src/domain/Dashboard.ts` define `DashboardData`, `MaterialSummary`, etc.
|
||||||
|
- `HomeScreen` calcula tudo inline sem usar esses tipos
|
||||||
|
- Refatorar para tipar explicitamente os dados computados com as interfaces do domínio
|
||||||
|
- Opcional: criar `DashboardUseCase` que chama `GET /dashboard` e retorna `DashboardData`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitetura — Offline-First (Fase Futura)
|
||||||
|
|
||||||
|
A infraestrutura está parcialmente preparada. Os itens abaixo compõem a implementação completa:
|
||||||
|
|
||||||
|
- [ ] **`SyncService`** — classe que drena a `sync_queue` e envia para a API
|
||||||
|
- Criar `src/application/sync/SyncService.ts`
|
||||||
|
- Ler linhas de `sync_queue` onde `synced = 0`
|
||||||
|
- Para cada item: chamar o use case correspondente (create/update/delete)
|
||||||
|
- Em sucesso: marcar `synced = 1` na tabela de origem e deletar da `sync_queue`
|
||||||
|
- Em conflito 409: aplicar last-write-wins (aceitar versão do servidor, atualizar local)
|
||||||
|
|
||||||
|
- [ ] **Registrar background task** (`src/infrastructure/backgroundSync.ts` — novo arquivo)
|
||||||
|
- Instalar `expo-background-fetch` + `expo-task-manager`
|
||||||
|
- Registrar task `BACKGROUND_SYNC` com `TaskManager.defineTask`
|
||||||
|
- Chamar `SyncService.sync()` dentro da task
|
||||||
|
- Registrar com `BackgroundFetch.registerTaskAsync` (intervalo mínimo: 15min)
|
||||||
|
|
||||||
|
- [ ] **Injetar adapters locais no container** (`src/infrastructure/container.ts`)
|
||||||
|
- Substituir `ApiFilamentRepository` por wrapper que escreve no SQLite e enfileira na `sync_queue`
|
||||||
|
- Substituir `ApiSpoolPresetRepository` por `LocalSpoolPresetRepository`
|
||||||
|
- `LocalSpoolPresetRepository.upsertSystemPresets()` já existe para sincronizar presets do sistema
|
||||||
|
|
||||||
|
- [ ] **Modo offline — fallback na leitura**
|
||||||
|
- Ao detectar ausência de rede (`NetInfo`), usar `LocalFilamentRepository` como fonte de leitura
|
||||||
|
- Exibir badge "Offline" no header quando sem conexão
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
- [ ] Configurar Jest + Testing Library (`@testing-library/react-native`)
|
||||||
|
- [ ] Testes unitários para use cases (`CreateFilamentUseCase`, cálculo de net weight)
|
||||||
|
- [ ] Testes de integração para stores Zustand
|
||||||
|
- [ ] Testes de snapshot para componentes `FilamentCard`, `StockBar`, `Badge`
|
||||||
|
- [ ] Mock de `httpClient` para testes de repositórios remotos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referência Rápida — Arquivos por Área
|
||||||
|
|
||||||
|
| Área | Arquivo |
|
||||||
|
| ------------------------------------ | ------------------------------------- |
|
||||||
|
| Login / Google OAuth | `app/(auth)/login.tsx` |
|
||||||
|
| Reset de senha | `app/(auth)/reset-password.tsx` |
|
||||||
|
| Verificar email | `app/(auth)/verify-email.tsx` |
|
||||||
|
| Lista de inventário + filtros + sort | `app/(app)/(tabs)/inventory.tsx` |
|
||||||
|
| Filtros (bottom sheet) | `app/(app)/inventory/filters.tsx` |
|
||||||
|
| Etiqueta SVG | `app/(app)/filaments/[id]/label.tsx` |
|
||||||
|
| QR Code | `app/(app)/filaments/[id]/qrcode.tsx` |
|
||||||
|
| Perfil | `app/(app)/(tabs)/profile.tsx` |
|
||||||
|
| DI Container | `src/infrastructure/container.ts` |
|
||||||
|
| DB local / schema | `src/adapters/local/database.ts` |
|
||||||
|
| Domínio Dashboard | `src/domain/Dashboard.ts` |
|
||||||
+82
-7
@@ -12,8 +12,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
|
|||||||
|
|
||||||
| Componente | Biblioteca | Observação |
|
| Componente | Biblioteca | Observação |
|
||||||
| ----------------- | ----------------------------------- | --------------------------------------------------------- |
|
| ----------------- | ----------------------------------- | --------------------------------------------------------- |
|
||||||
| Framework | `expo` ~51 | Managed Workflow |
|
| Framework | `expo` ~54 | Managed Workflow |
|
||||||
| Navigation | `expo-router` ~3 | File-system routing |
|
| Navigation | `expo-router` ~4 | File-system routing |
|
||||||
| Linguagem | TypeScript 5.x | strict mode |
|
| Linguagem | TypeScript 5.x | strict mode |
|
||||||
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
|
| Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) |
|
||||||
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
|
| Java Version | 17 | Obrigatório para React Native 0.81 (Java 24 quebra CMake) |
|
||||||
@@ -28,6 +28,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
|
|||||||
| Animations | `react-native-reanimated` | |
|
| Animations | `react-native-reanimated` | |
|
||||||
| QR Code render | `react-native-qrcode-svg` | Render de QR Code em tela |
|
| QR Code render | `react-native-qrcode-svg` | Render de QR Code em tela |
|
||||||
| Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build |
|
| Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build |
|
||||||
|
| File system | `expo-file-system/legacy` | Salvar arquivos no cache; usar import `/legacy` |
|
||||||
|
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ mobile/
|
|||||||
│ └── filaments/
|
│ └── filaments/
|
||||||
│ └── [id]/
|
│ └── [id]/
|
||||||
│ ├── qrcode.tsx ← Ver QR Code (react-native-qrcode-svg)
|
│ ├── qrcode.tsx ← Ver QR Code (react-native-qrcode-svg)
|
||||||
│ └── label.tsx ← Exportar Etiqueta SVG
|
│ └── label.tsx ← Exportar Etiqueta (PDF ou SVG)
|
||||||
│
|
│
|
||||||
└── src/
|
└── src/
|
||||||
├── domain/
|
├── domain/
|
||||||
@@ -297,14 +299,15 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
### Filaments
|
### Filaments
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
| ------ | ----------------------- | -------------------- |
|
| ------ | --------------------------- | -------------------------------------------- |
|
||||||
| GET | `/filaments` | Listar (com filtros) |
|
| GET | `/filaments` | Listar (com filtros) |
|
||||||
| POST | `/filaments` | Criar |
|
| POST | `/filaments` | Criar |
|
||||||
| GET | `/filaments/:id` | Detalhe |
|
| GET | `/filaments/:id` | Detalhe |
|
||||||
| PATCH | `/filaments/:id` | Atualizar |
|
| PATCH | `/filaments/:id` | Atualizar |
|
||||||
| DELETE | `/filaments/:id` | Excluir |
|
| DELETE | `/filaments/:id` | Excluir |
|
||||||
| GET | `/filaments/:id/qrcode` | QR Code SVG |
|
| GET | `/filaments/:id/qrcode` | QR Code PNG |
|
||||||
| GET | `/filaments/:id/label` | Etiqueta SVG |
|
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`) |
|
||||||
|
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`) |
|
||||||
|
|
||||||
### Spool Presets
|
### Spool Presets
|
||||||
|
|
||||||
@@ -329,7 +332,9 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
- [ ] Implementar sync background com `sync_queue` SQLite → API
|
- [ ] Implementar sync background com `sync_queue` SQLite → API
|
||||||
- [x] Integrar `react-native-qrcode-svg` para render real do QR Code
|
- [x] Integrar `react-native-qrcode-svg` para render real do QR Code
|
||||||
- [x] Scanner de QR Code com `expo-camera` v17 (ML Kit) → `scanner.tsx`
|
- [x] Scanner de QR Code com `expo-camera` v17 (ML Kit) → `scanner.tsx`
|
||||||
- [ ] Gerar SVG de etiqueta (integração com `/filaments/:id/label` do backend)
|
- [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg`
|
||||||
|
- [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf`
|
||||||
|
- [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
|
||||||
- [ ] Expo Notifications para alertas de estoque baixo
|
- [ ] Expo Notifications para alertas de estoque baixo
|
||||||
- [ ] Google OAuth com `expo-auth-session`
|
- [ ] Google OAuth com `expo-auth-session`
|
||||||
- [ ] Testes de integração com Jest + Testing Library
|
- [ ] Testes de integração com Jest + Testing Library
|
||||||
@@ -338,6 +343,15 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
|
|
||||||
## Mudanças Recentes (14/03/2026)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
|
### ✅ Exportação de Etiqueta PDF e SVG (`label.tsx`)
|
||||||
|
|
||||||
|
- **Pacotes instalados**: `expo-file-system`, `expo-sharing`, `react-native-worklets`
|
||||||
|
- **Atenção**: importar `expo-file-system` como `expo-file-system/legacy` — a API padrão do SDK 54 não exporta `EncodingType` nem `writeAsStringAsync` diretamente
|
||||||
|
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → `Sharing.shareAsync`
|
||||||
|
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → `Sharing.shareAsync`
|
||||||
|
- **Seletor de formato**: chips PDF/SVG no footer; padrão é PDF
|
||||||
|
- **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção
|
||||||
|
|
||||||
### ✅ Implementações Completadas
|
### ✅ Implementações Completadas
|
||||||
|
|
||||||
#### 1. **Delete Filament — Integração com API**
|
#### 1. **Delete Filament — Integração com API**
|
||||||
@@ -510,6 +524,67 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
|||||||
- **Resultado**: Ícone agora exibe com fundo turquesa arredondado, alinhado com design do Paper
|
- **Resultado**: Ícone agora exibe com fundo turquesa arredondado, alinhado com design do Paper
|
||||||
- **Impacto**: Visual correto da tela de login (G3-0) sincronizado com mockups
|
- **Impacto**: Visual correto da tela de login (G3-0) sincronizado com mockups
|
||||||
|
|
||||||
|
#### 7. **Color Picker — Modal Customizada (Substituindo Alert.prompt)**
|
||||||
|
|
||||||
|
- **Arquivos**: `app/(app)/inventory/new.tsx`, `app/(app)/inventory/[id]/edit.tsx`
|
||||||
|
- **Problema**: `Alert.prompt()` apresentava bugs e comportamentos inconsistentes em React Native/Expo
|
||||||
|
- **Solução**: Implementada Modal customizada com componentes nativos
|
||||||
|
- **Mudanças**:
|
||||||
|
- Adicionadas importações: `Modal`, `KeyboardAvoidingView`, `Platform` do React Native
|
||||||
|
- Novos estados: `isColorPickerOpen` (boolean), `colorInputValue` (string)
|
||||||
|
- Funções de controle:
|
||||||
|
- `handleOpenColorPicker()`: abre modal focando no input
|
||||||
|
- `handleConfirmColor()`: valida hex, atualiza cor, fecha modal
|
||||||
|
- `handleCancelColor()`: descarta input, fecha modal
|
||||||
|
- JSX: Modal envolta em Fragment com overlay semi-transparente
|
||||||
|
- Estilos novos: `modalOverlay`, `modalContent`, `modalCard`, `modalInput`, `modalButtons`, etc.
|
||||||
|
|
||||||
|
- **Código de exemplo**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
const [colorInputValue, setColorInputValue] = useState('');
|
||||||
|
|
||||||
|
const handleConfirmColor = (): void => {
|
||||||
|
if (!colorInputValue) {
|
||||||
|
Alert.alert('Erro', 'Digite um código hexadecimal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeHex(colorInputValue.trim());
|
||||||
|
if (isValidHex(normalized)) {
|
||||||
|
setSelectedColor(normalized);
|
||||||
|
setHexInput(normalized);
|
||||||
|
setIsColorPickerOpen(false);
|
||||||
|
setColorInputValue('');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No JSX:
|
||||||
|
<Modal visible={isColorPickerOpen} transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="#FF5733"
|
||||||
|
value={colorInputValue}
|
||||||
|
onChangeText={setColorInputValue}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{/* Botões confirmar/cancelar */}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Impacto**:
|
||||||
|
- ✅ Color picker agora funciona consistentemente em todas plataformas
|
||||||
|
- ✅ UX melhorada com feedback visual imediato
|
||||||
|
- ✅ Suporte completo a teclado (autoFocus, KeyboardAvoidingView)
|
||||||
|
- ✅ Validação de hex pré-digitação com `isValidHex()` e `normalizeHex()`
|
||||||
|
|
||||||
### Arquitetura Mantida
|
### Arquitetura Mantida
|
||||||
|
|
||||||
- Todos use cases injetados via `container.ts` (Dependency Injection)
|
- Todos use cases injetados via `container.ts` (Dependency Injection)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useRouter } from 'expo-router';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
import { isUserOwnedPreset } from '@domain/SpoolPreset';
|
import { isUserOwnedPreset } from '@domain/SpoolPreset';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
|
import { deletePresetUseCase } from '@infrastructure/container';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tela de Presets de Carretéis — M4-0
|
* Tela de Presets de Carretéis — M4-0
|
||||||
@@ -22,7 +24,23 @@ export default function ConfigScreen(): React.ReactElement {
|
|||||||
`Deseja excluir o preset "${name}"?`,
|
`Deseja excluir o preset "${name}"?`,
|
||||||
[
|
[
|
||||||
{ text: 'Cancelar', style: 'cancel' },
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
{ text: 'Excluir', style: 'destructive', onPress: () => removePreset(id) },
|
{
|
||||||
|
text: 'Excluir',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const user = useAuthStore.getState().user;
|
||||||
|
if (!user) {
|
||||||
|
Alert.alert('Erro', 'Usuário não autenticado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deletePresetUseCase.execute(id, user.id);
|
||||||
|
removePreset(id);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Erro', `Falha ao deletar: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import { z } from 'zod';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
import { Input } from '@presentation/components/ui/Input';
|
import { Input } from '@presentation/components/ui/Input';
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
|
import type { UpdateSpoolPresetInput } from '@domain/SpoolPreset';
|
||||||
|
import { updatePresetUseCase, deletePresetUseCase } from '@infrastructure/container';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, 'Nome obrigatório'),
|
name: z.string().min(1, 'Nome obrigatório'),
|
||||||
@@ -64,9 +67,20 @@ export default function EditPresetScreen(): React.ReactElement {
|
|||||||
{
|
{
|
||||||
text: 'Excluir',
|
text: 'Excluir',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const user = useAuthStore.getState().user;
|
||||||
|
if (!user) {
|
||||||
|
Alert.alert('Erro', 'Usuário não autenticado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePresetUseCase.execute(preset!.id, user.id);
|
||||||
removePreset(preset!.id);
|
removePreset(preset!.id);
|
||||||
router.replace('/(app)/(tabs)/config' as never);
|
router.replace('/(app)/(tabs)/config' as never);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Erro', `Falha ao deletar: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -76,15 +90,22 @@ export default function EditPresetScreen(): React.ReactElement {
|
|||||||
async function onSubmit(data: FormData): Promise<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// TODO: UpdatePresetUseCase via DI container
|
const user = useAuthStore.getState().user;
|
||||||
updatePreset({
|
if (!user) {
|
||||||
...preset!,
|
Alert.alert('Erro', 'Usuário não autenticado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: UpdateSpoolPresetInput = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
spoolWeightG: Number(data.spoolWeightG),
|
spoolWeightG: Number(data.spoolWeightG),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const updated = await updatePresetUseCase.execute(preset!.id, user.id, input);
|
||||||
|
updatePreset(updated);
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch (error) {
|
||||||
Alert.alert('Erro', 'Não foi possível salvar as alterações.');
|
Alert.alert('Erro', `Não foi possível salvar as alterações: ${(error as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { z } from 'zod';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
|
import { useAuthStore } from '@store/authStore';
|
||||||
import { Input } from '@presentation/components/ui/Input';
|
import { Input } from '@presentation/components/ui/Input';
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
import type { SpoolPreset } from '@domain/SpoolPreset';
|
import type { SpoolPreset, CreateSpoolPresetInput } from '@domain/SpoolPreset';
|
||||||
|
import { createPresetUseCase } from '@infrastructure/container';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, 'Nome obrigatório'),
|
name: z.string().min(1, 'Nome obrigatório'),
|
||||||
@@ -40,19 +42,22 @@ export default function NewPresetScreen(): React.ReactElement {
|
|||||||
async function onSubmit(data: FormData): Promise<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// TODO: CreatePresetUseCase via DI container
|
const user = useAuthStore.getState().user;
|
||||||
const preset: SpoolPreset = {
|
if (!user) {
|
||||||
id: `user-${Date.now()}`,
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: CreateSpoolPresetInput = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
spoolWeightG: Number(data.spoolWeightG),
|
spoolWeightG: Number(data.spoolWeightG),
|
||||||
isSystem: false,
|
|
||||||
userId: 'me',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const preset = await createPresetUseCase.execute(user.id, input);
|
||||||
addPreset(preset);
|
addPreset(preset);
|
||||||
|
Alert.alert('Sucesso', 'Preset criado com sucesso');
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch (error) {
|
||||||
Alert.alert('Erro', 'Não foi possível salvar o preset.');
|
Alert.alert('Erro', `Não foi possível salvar o preset: ${(error as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {
|
|||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from '@store/filamentStore';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
import { calcFilamentPercentage } from '@domain/Filament';
|
import { calcFilamentPercentage } from '@domain/Filament';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
import { formatWeight } from '@shared/utils/filament';
|
import { formatWeight } from '@shared/utils/filament';
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
|
import { httpClient } from '@adapters/remote/httpClient';
|
||||||
|
|
||||||
type LabelSize = '50x30' | '62x29' | '38x25';
|
type LabelSize = '50x30' | '62x29' | '38x25';
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
|
const preset = filament ? presets.find((p) => p.id === filament.spoolPresetId) : undefined;
|
||||||
|
|
||||||
const [selectedSize, setSelectedSize] = useState<LabelSize>('50x30');
|
const [selectedSize, setSelectedSize] = useState<LabelSize>('50x30');
|
||||||
|
const [selectedFormat, setSelectedFormat] = useState<'pdf' | 'svg'>('pdf');
|
||||||
const [enabledContent, setEnabledContent] = useState<Set<ContentOption>>(
|
const [enabledContent, setEnabledContent] = useState<Set<ContentOption>>(
|
||||||
new Set(['color', 'name', 'material_brand', 'net_weight', 'print_temp', 'qrcode']),
|
new Set(['color', 'name', 'material_brand', 'net_weight', 'print_temp', 'qrcode']),
|
||||||
);
|
);
|
||||||
@@ -68,9 +72,47 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExport(): void {
|
async function handleExport(): Promise<void> {
|
||||||
// TODO: Generate SVG and share via expo-sharing
|
const [widthStr, heightStr] = selectedSize.split('x');
|
||||||
Alert.alert('Exportar SVG', 'Funcionalidade de exportação SVG será implementada na integração com o backend.');
|
const width_mm = Number(widthStr);
|
||||||
|
const height_mm = Number(heightStr);
|
||||||
|
const isPdf = selectedFormat === 'pdf';
|
||||||
|
const fields = Array.from(enabledContent).join(',');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isPdf) {
|
||||||
|
const response = await httpClient.get<ArrayBuffer>(
|
||||||
|
`/filaments/${id}/label.pdf`,
|
||||||
|
{ params: { width_mm, height_mm, fields }, responseType: 'arraybuffer' },
|
||||||
|
);
|
||||||
|
const base64 = btoa(
|
||||||
|
String.fromCharCode(...new Uint8Array(response.data)),
|
||||||
|
);
|
||||||
|
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
|
||||||
|
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' });
|
||||||
|
} else {
|
||||||
|
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await httpClient.get<string>(
|
||||||
|
`/filaments/${id}/label.svg`,
|
||||||
|
{ params: { width_mm, height_mm, fields }, responseType: 'text' },
|
||||||
|
);
|
||||||
|
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
|
||||||
|
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' });
|
||||||
|
} else {
|
||||||
|
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Erro', String(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeLabel = LABEL_SIZES.find((s) => s.id === selectedSize);
|
const sizeLabel = LABEL_SIZES.find((s) => s.id === selectedSize);
|
||||||
@@ -174,8 +216,21 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.formatRow}>
|
||||||
|
{(['pdf', 'svg'] as const).map((fmt) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={fmt}
|
||||||
|
onPress={() => setSelectedFormat(fmt)}
|
||||||
|
style={[styles.formatChip, selectedFormat === fmt && styles.formatChipActive]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.formatChipText, selectedFormat === fmt && styles.formatChipTextActive]}>
|
||||||
|
{fmt.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
<Button
|
<Button
|
||||||
label="Exportar SVG"
|
label={`Exportar ${selectedFormat.toUpperCase()}`}
|
||||||
leftIcon={<Ionicons name="download-outline" size={18} color={colors.bgBase} />}
|
leftIcon={<Ionicons name="download-outline" size={18} color={colors.bgBase} />}
|
||||||
onPress={handleExport}
|
onPress={handleExport}
|
||||||
/>
|
/>
|
||||||
@@ -300,8 +355,27 @@ const styles = StyleSheet.create({
|
|||||||
contentChipTextActive: { color: colors.accent },
|
contentChipTextActive: { color: colors.accent },
|
||||||
footer: {
|
footer: {
|
||||||
padding: spacing[5],
|
padding: spacing[5],
|
||||||
|
gap: spacing[3],
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: colors.border,
|
||||||
backgroundColor: colors.bgBase,
|
backgroundColor: colors.bgBase,
|
||||||
},
|
},
|
||||||
|
formatRow: { flexDirection: 'row', gap: spacing[2] },
|
||||||
|
formatChip: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing[2],
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
backgroundColor: colors.bgSurface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
formatChipActive: { borderColor: colors.accent, backgroundColor: colors.accentMuted },
|
||||||
|
formatChipText: {
|
||||||
|
fontFamily: typography.fontFamily.ui,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
formatChipTextActive: { color: colors.accent },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
|
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
@@ -50,7 +50,8 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
|
const [selectedMaterial, setSelectedMaterial] = useState<Material>((filament?.material as Material) ?? 'PLA');
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
const [colorInputValue, setColorInputValue] = useState('');
|
||||||
|
|
||||||
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
|
const { control, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -86,31 +87,29 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenColorPicker = (): void => {
|
const handleOpenColorPicker = (): void => {
|
||||||
if (isOpeningColorPicker) return;
|
setColorInputValue(selectedColor);
|
||||||
setIsOpeningColorPicker(true);
|
setIsColorPickerOpen(true);
|
||||||
Alert.prompt(
|
};
|
||||||
'Cor Customizada',
|
|
||||||
'Digite um código de cor hexadecimal (ex: #FF5733)',
|
const handleConfirmColor = (): void => {
|
||||||
[
|
if (!colorInputValue) {
|
||||||
{ text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
|
Alert.alert('Erro', 'Digite um código hexadecimal');
|
||||||
{
|
return;
|
||||||
text: 'Confirmar',
|
}
|
||||||
onPress: (value) => {
|
const normalized = normalizeHex(colorInputValue.trim());
|
||||||
setIsOpeningColorPicker(false);
|
|
||||||
if (!value) return;
|
|
||||||
const normalized = normalizeHex(value.trim());
|
|
||||||
if (isValidHex(normalized)) {
|
if (isValidHex(normalized)) {
|
||||||
setSelectedColor(normalized);
|
setSelectedColor(normalized);
|
||||||
setHexInput(normalized);
|
setHexInput(normalized);
|
||||||
|
setIsColorPickerOpen(false);
|
||||||
|
setColorInputValue('');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
|
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
],
|
const handleCancelColor = (): void => {
|
||||||
'plain-text',
|
setIsColorPickerOpen(false);
|
||||||
selectedColor,
|
setColorInputValue('');
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (): Promise<void> => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
@@ -183,6 +182,33 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Modal visible={isColorPickerOpen} transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalContent}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Código Hexadecimal</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
placeholder="#FF5733"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={colorInputValue}
|
||||||
|
onChangeText={setColorInputValue}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity onPress={handleCancelColor} style={styles.modalBtnCancel}>
|
||||||
|
<Text style={styles.modalBtnTextCancel}>Cancelar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleConfirmColor} style={styles.modalBtnConfirm}>
|
||||||
|
<Text style={styles.modalBtnTextConfirm}>Confirmar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<SafeAreaView style={styles.safe}>
|
<SafeAreaView style={styles.safe}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -203,7 +229,7 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
<View style={styles.colorPreviewRow}>
|
<View style={styles.colorPreviewRow}>
|
||||||
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
|
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
|
||||||
<Text style={styles.hexValue}>{hexInput}</Text>
|
<Text style={styles.hexValue}>{hexInput}</Text>
|
||||||
<TouchableOpacity><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
|
<TouchableOpacity onPress={handleOpenColorPicker}><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.colorPalette}>
|
<View style={styles.colorPalette}>
|
||||||
{PRESET_COLORS.map((c) => (
|
{PRESET_COLORS.map((c) => (
|
||||||
@@ -371,6 +397,7 @@ export default function EditFilamentScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,4 +448,15 @@ const styles = StyleSheet.create({
|
|||||||
resultDot: { width: 8, height: 8, borderRadius: radius.full },
|
resultDot: { width: 8, height: 8, borderRadius: radius.full },
|
||||||
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
|
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
|
||||||
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
|
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
|
||||||
|
modalContent: { width: '85%', maxWidth: 320 },
|
||||||
|
modalCard: { backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[5], gap: spacing[4] },
|
||||||
|
modalTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
|
||||||
|
modalInput: { backgroundColor: colors.bgBase, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, paddingHorizontal: spacing[3], paddingVertical: spacing[2], fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
|
||||||
|
modalButtons: { flexDirection: 'row', gap: spacing[3] },
|
||||||
|
modalBtnCancel: { flex: 1, backgroundColor: colors.bgHover, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
|
||||||
|
modalBtnTextCancel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
|
||||||
|
modalBtnConfirm: { flex: 1, backgroundColor: colors.accent, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
|
||||||
|
modalBtnTextConfirm: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.bgBase },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
|
View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
@@ -49,7 +49,8 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
|
const [selectedMaterial, setSelectedMaterial] = useState<Material>('PLA');
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(systemPresets[0]?.id ?? null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isOpeningColorPicker, setIsOpeningColorPicker] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
const [colorInputValue, setColorInputValue] = useState('');
|
||||||
|
|
||||||
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
|
// Quando os presets carregam (via layout), seleciona o primeiro sistema automaticamente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,33 +70,32 @@ export default function NewFilamentScreen(): React.ReactElement {
|
|||||||
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
|
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
|
||||||
: 0;
|
: 0;
|
||||||
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
|
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
|
||||||
function handleOpenColorPicker(): void {
|
|
||||||
if (isOpeningColorPicker) return;
|
const handleOpenColorPicker = (): void => {
|
||||||
setIsOpeningColorPicker(true);
|
setColorInputValue(selectedColor);
|
||||||
Alert.prompt(
|
setIsColorPickerOpen(true);
|
||||||
'Cor Customizada',
|
};
|
||||||
'Digite um código de cor hexadecimal (ex: #FF5733)',
|
|
||||||
[
|
const handleConfirmColor = (): void => {
|
||||||
{ text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
|
if (!colorInputValue) {
|
||||||
{
|
Alert.alert('Erro', 'Digite um código hexadecimal');
|
||||||
text: 'Confirmar',
|
return;
|
||||||
onPress: (value) => {
|
}
|
||||||
setIsOpeningColorPicker(false);
|
const normalized = normalizeHex(colorInputValue.trim());
|
||||||
if (!value) return;
|
|
||||||
const normalized = normalizeHex(value.trim());
|
|
||||||
if (isValidHex(normalized)) {
|
if (isValidHex(normalized)) {
|
||||||
setSelectedColor(normalized);
|
setSelectedColor(normalized);
|
||||||
setHexInput(normalized);
|
setHexInput(normalized);
|
||||||
|
setIsColorPickerOpen(false);
|
||||||
|
setColorInputValue('');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
|
Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
],
|
const handleCancelColor = (): void => {
|
||||||
'plain-text',
|
setIsColorPickerOpen(false);
|
||||||
selectedColor,
|
setColorInputValue('');
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function onSubmit(data: FormData): Promise<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
@@ -132,7 +132,34 @@ function handleOpenColorPicker(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe}> onPress={handleOpenColorPicker}
|
<>
|
||||||
|
<Modal visible={isColorPickerOpen} transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.modalContent}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Código Hexadecimal</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
placeholder="#FF5733"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={colorInputValue}
|
||||||
|
onChangeText={setColorInputValue}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity onPress={handleCancelColor} style={styles.modalBtnCancel}>
|
||||||
|
<Text style={styles.modalBtnTextCancel}>Cancelar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleConfirmColor} style={styles.modalBtnConfirm}>
|
||||||
|
<Text style={styles.modalBtnTextConfirm}>Confirmar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -150,7 +177,7 @@ function handleOpenColorPicker(): void {
|
|||||||
<View style={styles.colorPreviewRow}>
|
<View style={styles.colorPreviewRow}>
|
||||||
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
|
<View style={[styles.colorSquare, { backgroundColor: selectedColor }]} />
|
||||||
<Text style={styles.hexValue}>{hexInput}</Text>
|
<Text style={styles.hexValue}>{hexInput}</Text>
|
||||||
<TouchableOpacity><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
|
<TouchableOpacity onPress={handleOpenColorPicker}><Ionicons name="pencil-outline" size={18} color={colors.textSecondary} /></TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.colorPalette}>
|
<View style={styles.colorPalette}>
|
||||||
{PRESET_COLORS.map((c) => (
|
{PRESET_COLORS.map((c) => (
|
||||||
@@ -318,6 +345,7 @@ function handleOpenColorPicker(): void {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,4 +393,15 @@ const styles = StyleSheet.create({
|
|||||||
resultDot: { width: 8, height: 8, borderRadius: radius.full },
|
resultDot: { width: 8, height: 8, borderRadius: radius.full },
|
||||||
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
|
resultPct: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.bold, color: colors.accent },
|
||||||
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
|
footer: { padding: spacing[5], borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bgBase },
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
|
||||||
|
modalContent: { width: '85%', maxWidth: 320 },
|
||||||
|
modalCard: { backgroundColor: colors.bgSurface, borderRadius: radius.lg, padding: spacing[5], gap: spacing[4] },
|
||||||
|
modalTitle: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.base, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
|
||||||
|
modalInput: { backgroundColor: colors.bgBase, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, paddingHorizontal: spacing[3], paddingVertical: spacing[2], fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.base, color: colors.textPrimary },
|
||||||
|
modalButtons: { flexDirection: 'row', gap: spacing[3] },
|
||||||
|
modalBtnCancel: { flex: 1, backgroundColor: colors.bgHover, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
|
||||||
|
modalBtnTextCancel: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.textPrimary },
|
||||||
|
modalBtnConfirm: { flex: 1, backgroundColor: colors.accent, paddingVertical: spacing[3], borderRadius: radius.md, alignItems: 'center' },
|
||||||
|
modalBtnTextConfirm: { fontFamily: typography.fontFamily.ui, fontSize: typography.fontSize.sm, fontWeight: typography.fontWeight.semibold, color: colors.bgBase },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200" style="width: 38px; height: 38px; flex-shrink: 0">
|
||||||
|
<g stroke="#C9C1B0" stroke-width="10" fill="none">
|
||||||
|
<path d="M 23.49 41.51 A 12 12 0 0 1 23.49 58.49" />
|
||||||
|
<path d="M 34.80 30.20 A 28 28 0 0 1 34.80 69.80" />
|
||||||
|
<path d="M 46.11 18.89 A 44 44 0 0 1 46.11 81.11" />
|
||||||
|
<path d="M 57.43 7.57 A 60 60 0 0 1 57.43 92.43" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Carregar variáveis do .env
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building Android Release APK with mise..."
|
||||||
|
echo "API URL: $EXPO_PUBLIC_API_URL"
|
||||||
|
|
||||||
|
mise exec -- npx expo prebuild --clean
|
||||||
|
|
||||||
|
# Exportar variáveis para o processo Gradle
|
||||||
|
export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://meowspool.felipecncloud.com/api/v1}"
|
||||||
|
export EXPO_PUBLIC_GOOGLE_CLIENT_ID="${EXPO_PUBLIC_GOOGLE_CLIENT_ID}"
|
||||||
|
export EXPO_PUBLIC_APP_SCHEME="${EXPO_PUBLIC_APP_SCHEME:-meowspool}"
|
||||||
|
export EXPO_PUBLIC_APP_ENV="${EXPO_PUBLIC_APP_ENV:-production}"
|
||||||
|
|
||||||
|
mise exec -- ./android/gradlew -p android assembleRelease
|
||||||
|
|
||||||
|
echo "Build complete!"
|
||||||
|
echo "APK: android/app/build/outputs/apk/release/app-release.apk"
|
||||||
Generated
+14
-30
@@ -17,10 +17,12 @@
|
|||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-worklets": "^0.7.4",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
},
|
},
|
||||||
@@ -1405,7 +1408,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
|
||||||
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
|
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -3870,7 +3872,7 @@
|
|||||||
"version": "19.1.17",
|
"version": "19.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -5593,7 +5595,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -6793,6 +6795,15 @@
|
|||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-sharing": {
|
||||||
|
"version": "14.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
|
||||||
|
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-splash-screen": {
|
"node_modules/expo-splash-screen": {
|
||||||
"version": "31.0.13",
|
"version": "31.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
|
||||||
@@ -10826,26 +10837,6 @@
|
|||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
|
||||||
"version": "19.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"scheduler": "^0.27.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-dom/node_modules/scheduler": {
|
|
||||||
"version": "0.27.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
@@ -11057,7 +11048,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz",
|
||||||
"integrity": "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==",
|
"integrity": "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/plugin-transform-arrow-functions": "7.27.1",
|
"@babel/plugin-transform-arrow-functions": "7.27.1",
|
||||||
"@babel/plugin-transform-class-properties": "7.27.1",
|
"@babel/plugin-transform-class-properties": "7.27.1",
|
||||||
@@ -11082,7 +11072,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
|
||||||
"integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
|
"integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -11099,7 +11088,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
|
||||||
"integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
|
"integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||||
"@babel/helper-compilation-targets": "^7.27.2",
|
"@babel/helper-compilation-targets": "^7.27.2",
|
||||||
@@ -11120,7 +11108,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
|
||||||
"integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
|
"integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -11136,7 +11123,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
|
||||||
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
|
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
|
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
|
||||||
@@ -11153,7 +11139,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
||||||
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.27.1",
|
||||||
@@ -11173,7 +11158,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,10 +20,12 @@
|
|||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-worklets": "^0.7.4",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user