diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 992417a..8123d27 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,11 @@ "Bash(npm install expo-camera)", "Bash(npm install expo-camera --legacy-peer-deps)", "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" ] } } diff --git a/assets/nfc.svg b/assets/nfc.svg new file mode 100644 index 0000000..4d3ace0 --- /dev/null +++ b/assets/nfc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 63f33dc..f5a5337 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -348,6 +348,17 @@ dependencies = [ "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]] name = "built" version = "0.8.0" @@ -1555,6 +1566,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "litemap" version = "0.8.1" @@ -1585,6 +1602,23 @@ dependencies = [ "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]] name = "lru-slab" version = "0.1.2" @@ -1626,6 +1660,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1646,6 +1686,7 @@ dependencies = [ "image", "jsonwebtoken", "oauth2", + "printpdf", "qrcode", "reqwest 0.12.28", "serde", @@ -1849,6 +1890,15 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "parking" version = "2.2.1" @@ -1984,6 +2034,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2028,6 +2087,18 @@ dependencies = [ "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]] name = "proc-macro-error" version = "1.0.4" @@ -3444,6 +3515,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typenum" version = "1.19.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ea01224..131be09 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -62,6 +62,9 @@ qrcode = "0.14" image = "0.25" base64 = "0.22" +# PDF generation +printpdf = "0.7" + # Async trait async-trait = "0.1" diff --git a/backend/TODO.md b/backend/TODO.md new file mode 100644 index 0000000..d68b55f --- /dev/null +++ b/backend/TODO.md @@ -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 ` + - 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/` | diff --git a/backend/agent.md b/backend/agent.md index 444855a..8130a85 100644 --- a/backend/agent.md +++ b/backend/agent.md @@ -24,6 +24,9 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f | Validação | `validator` | 0.18 | | HTTP Client | `reqwest` (json, rustls-tls) | 0.12 | | 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 | | GET | `/:id/qrcode` | `get_qrcode_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`):** @@ -370,11 +374,36 @@ O backend implementa **last-write-wins com timestamp**: ### Etiqueta SVG (`GET /filaments/:id/label.svg`) -- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30) -- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido +- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional) +- Retorna SVG com os campos selecionados embutidos - `Content-Type: image/svg+xml` - `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 @@ -390,6 +419,24 @@ O backend implementa **last-write-wins com timestamp**: ## 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` 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 **Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()` diff --git a/backend/bruno-routes/MewoSpool/Auth/Login.yml b/backend/bruno-routes/MewoSpool/Auth/Login.yml index f38d739..bd02208 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Login.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Login.yml @@ -5,7 +5,7 @@ info: http: method: POST - url: http://0.0.0.0:8080/api/v1/auth/login + url: https://meowspool.felipecncloud.com/api/v1/auth/login body: type: json data: |- diff --git a/backend/src/adapters/inbound/filament_handler.rs b/backend/src/adapters/inbound/filament_handler.rs index feab234..cbbc4a1 100644 --- a/backend/src/adapters/inbound/filament_handler.rs +++ b/backend/src/adapters/inbound/filament_handler.rs @@ -46,6 +46,9 @@ pub struct LabelQuery { pub width_mm: u32, #[serde(default = "default_height_mm")] 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, } 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, + Extension(user): Extension, + Path(id): Path, + Query(query): Query, +) -> Result { + 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( State(state): State, Extension(user): Extension, @@ -294,7 +321,7 @@ pub async fn export_label_handler( ) -> Result { let svg = state .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?; let filename = format!("meowspool-label-{id}.svg"); diff --git a/backend/src/application/filament_service.rs b/backend/src/application/filament_service.rs index 9047076..7ffa6b5 100644 --- a/backend/src/application/filament_service.rs +++ b/backend/src/application/filament_service.rs @@ -65,6 +65,41 @@ pub struct FilamentService { preset_repo: Arc, } +/// 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 { pub fn new(repo: Arc, preset_repo: Arc) -> Self { Self { repo, preset_repo } @@ -255,7 +290,9 @@ impl FilamentService { user_id: Uuid, width_mm: u32, height_mm: u32, + fields: Option<&str>, ) -> Result { + let f = LabelFields::from_str(fields); let filament = self.get(id, user_id).await?; // Gerar QR Code como PNG base64 para embutir no SVG @@ -274,37 +311,191 @@ impl FilamentService { let brand = &filament.brand; let color = &filament.color_hex; + let color_swatch = if f.color { + format!(r#" "#, + h = height_px - 12, color = color) + } else { String::new() }; + + let text_name = if f.name { + format!(r##" {model_name}"##, + model_name = escape_xml(model_name)) + } else { String::new() }; + + let text_meta = if f.material_brand { + format!(r##" {brand} · {material}"##, + brand = escape_xml(brand), material = escape_xml(&material)) + } else { String::new() }; + + let text_weight = if f.net_weight { + format!(r##" {net_weight}g"##, + net_weight = net_weight) + } else { String::new() }; + + let qr_element = if f.qrcode { + format!(r##" "##, + 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!( r##" - - - - {model_name} - {brand} · {material} - {net_weight}g - - +{color_swatch} +{text_name} +{text_meta} +{text_weight} +{qr_element} "##, width_px = width_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) } + + // ------------------------------------------------------------------------- + // PDF Label + // ------------------------------------------------------------------------- + + pub async fn generate_label_pdf( + &self, + id: Uuid, + user_id: Uuid, + width_mm: u32, + height_mm: u32, + fields: Option<&str>, + ) -> Result, 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 { diff --git a/backend/src/router.rs b/backend/src/router.rs index f902e39..bc93732 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -85,6 +85,7 @@ pub fn build(db: PgPool, config: Config) -> Router { .route("/filaments/:id", delete(filament_handler::delete_filament_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.pdf", get(filament_handler::export_label_pdf_handler)) // Spool Presets .route("/spool-presets", get(spool_preset_handler::list_presets_handler)) .route("/spool-presets", post(spool_preset_handler::create_preset_handler)) diff --git a/mobile/.claude/settings.local.json b/mobile/.claude/settings.local.json new file mode 100644 index 0000000..c0fb096 --- /dev/null +++ b/mobile/.claude/settings.local.json @@ -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" + ] + } +} diff --git a/mobile/TODO.md b/mobile/TODO.md new file mode 100644 index 0000000..24ae99e --- /dev/null +++ b/mobile/TODO.md @@ -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` | diff --git a/mobile/agent.md b/mobile/agent.md index ec4419e..d47335b 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -12,8 +12,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi | Componente | Biblioteca | Observação | | ----------------- | ----------------------------------- | --------------------------------------------------------- | -| Framework | `expo` ~51 | Managed Workflow | -| Navigation | `expo-router` ~3 | File-system routing | +| Framework | `expo` ~54 | Managed Workflow | +| Navigation | `expo-router` ~4 | File-system routing | | Linguagem | TypeScript 5.x | strict mode | | Node Version | 22 LTS | Gerenciado via `mise` (`.mise.toml`) | | 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` | | | 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 | +| 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/ │ └── [id]/ │ ├── qrcode.tsx ← Ver QR Code (react-native-qrcode-svg) -│ └── label.tsx ← Exportar Etiqueta SVG +│ └── label.tsx ← Exportar Etiqueta (PDF ou SVG) │ └── src/ ├── domain/ @@ -296,15 +298,16 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) ### Filaments -| Método | Rota | Descrição | -| ------ | ----------------------- | -------------------- | -| GET | `/filaments` | Listar (com filtros) | -| POST | `/filaments` | Criar | -| GET | `/filaments/:id` | Detalhe | -| PATCH | `/filaments/:id` | Atualizar | -| DELETE | `/filaments/:id` | Excluir | -| GET | `/filaments/:id/qrcode` | QR Code SVG | -| GET | `/filaments/:id/label` | Etiqueta SVG | +| Método | Rota | Descrição | +| ------ | --------------------------- | -------------------------------------------- | +| GET | `/filaments` | Listar (com filtros) | +| POST | `/filaments` | Criar | +| GET | `/filaments/:id` | Detalhe | +| PATCH | `/filaments/:id` | Atualizar | +| DELETE | `/filaments/:id` | Excluir | +| GET | `/filaments/:id/qrcode` | QR Code PNG | +| 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 @@ -329,7 +332,9 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) - [ ] Implementar sync background com `sync_queue` SQLite → API - [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` -- [ ] 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 - [ ] Google OAuth com `expo-auth-session` - [ ] 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) +### ✅ 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 #### 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 - **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: + + + + + + {/* Botões confirmar/cancelar */} + + + + + ``` + +- **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 - Todos use cases injetados via `container.ts` (Dependency Injection) diff --git a/mobile/app/(app)/(tabs)/config.tsx b/mobile/app/(app)/(tabs)/config.tsx index 6b4faf9..8489eeb 100644 --- a/mobile/app/(app)/(tabs)/config.tsx +++ b/mobile/app/(app)/(tabs)/config.tsx @@ -6,8 +6,10 @@ import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { usePresetStore } from '@store/presetStore'; +import { useAuthStore } from '@store/authStore'; import { isUserOwnedPreset } from '@domain/SpoolPreset'; import { colors, typography, spacing, radius } from '@shared/theme'; +import { deletePresetUseCase } from '@infrastructure/container'; /** * Tela de Presets de Carretéis — M4-0 @@ -22,7 +24,23 @@ export default function ConfigScreen(): React.ReactElement { `Deseja excluir o preset "${name}"?`, [ { 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}`); + } + }, + }, ], ); } diff --git a/mobile/app/(app)/config/presets/[id]/edit.tsx b/mobile/app/(app)/config/presets/[id]/edit.tsx index e0d76b7..8e92bcc 100644 --- a/mobile/app/(app)/config/presets/[id]/edit.tsx +++ b/mobile/app/(app)/config/presets/[id]/edit.tsx @@ -9,9 +9,12 @@ import { z } from 'zod'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { usePresetStore } from '@store/presetStore'; +import { useAuthStore } from '@store/authStore'; import { Input } from '@presentation/components/ui/Input'; import { Button } from '@presentation/components/ui/Button'; import { colors, typography, spacing, radius } from '@shared/theme'; +import type { UpdateSpoolPresetInput } from '@domain/SpoolPreset'; +import { updatePresetUseCase, deletePresetUseCase } from '@infrastructure/container'; const schema = z.object({ name: z.string().min(1, 'Nome obrigatório'), @@ -64,9 +67,20 @@ export default function EditPresetScreen(): React.ReactElement { { text: 'Excluir', style: 'destructive', - onPress: () => { - removePreset(preset!.id); - router.replace('/(app)/(tabs)/config' as never); + 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); + 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 { setIsLoading(true); try { - // TODO: UpdatePresetUseCase via DI container - updatePreset({ - ...preset!, + const user = useAuthStore.getState().user; + if (!user) { + Alert.alert('Erro', 'Usuário não autenticado'); + return; + } + + const input: UpdateSpoolPresetInput = { name: data.name, spoolWeightG: Number(data.spoolWeightG), - }); + }; + + const updated = await updatePresetUseCase.execute(preset!.id, user.id, input); + updatePreset(updated); router.back(); - } catch { - Alert.alert('Erro', 'Não foi possível salvar as alterações.'); + } catch (error) { + Alert.alert('Erro', `Não foi possível salvar as alterações: ${(error as Error).message}`); } finally { setIsLoading(false); } diff --git a/mobile/app/(app)/config/presets/new.tsx b/mobile/app/(app)/config/presets/new.tsx index 6e5b3f2..8913659 100644 --- a/mobile/app/(app)/config/presets/new.tsx +++ b/mobile/app/(app)/config/presets/new.tsx @@ -9,10 +9,12 @@ import { z } from 'zod'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { usePresetStore } from '@store/presetStore'; +import { useAuthStore } from '@store/authStore'; import { Input } from '@presentation/components/ui/Input'; import { Button } from '@presentation/components/ui/Button'; 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({ name: z.string().min(1, 'Nome obrigatório'), @@ -40,19 +42,22 @@ export default function NewPresetScreen(): React.ReactElement { async function onSubmit(data: FormData): Promise { setIsLoading(true); try { - // TODO: CreatePresetUseCase via DI container - const preset: SpoolPreset = { - id: `user-${Date.now()}`, + const user = useAuthStore.getState().user; + if (!user) { + throw new Error('Usuário não autenticado'); + } + + const input: CreateSpoolPresetInput = { name: data.name, spoolWeightG: Number(data.spoolWeightG), - isSystem: false, - userId: 'me', - createdAt: new Date().toISOString(), }; + + const preset = await createPresetUseCase.execute(user.id, input); addPreset(preset); + Alert.alert('Sucesso', 'Preset criado com sucesso'); router.back(); - } catch { - Alert.alert('Erro', 'Não foi possível salvar o preset.'); + } catch (error) { + Alert.alert('Erro', `Não foi possível salvar o preset: ${(error as Error).message}`); } finally { setIsLoading(false); } diff --git a/mobile/app/(app)/filaments/[id]/label.tsx b/mobile/app/(app)/filaments/[id]/label.tsx index 6ac3a33..5fca37a 100644 --- a/mobile/app/(app)/filaments/[id]/label.tsx +++ b/mobile/app/(app)/filaments/[id]/label.tsx @@ -5,12 +5,15 @@ import { import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; 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 { usePresetStore } from '@store/presetStore'; import { calcFilamentPercentage } from '@domain/Filament'; import { colors, typography, spacing, radius } from '@shared/theme'; import { formatWeight } from '@shared/utils/filament'; import { Button } from '@presentation/components/ui/Button'; +import { httpClient } from '@adapters/remote/httpClient'; 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 [selectedSize, setSelectedSize] = useState('50x30'); + const [selectedFormat, setSelectedFormat] = useState<'pdf' | 'svg'>('pdf'); const [enabledContent, setEnabledContent] = useState>( new Set(['color', 'name', 'material_brand', 'net_weight', 'print_temp', 'qrcode']), ); @@ -68,9 +72,47 @@ export default function LabelScreen(): React.ReactElement { }); } - function handleExport(): void { - // TODO: Generate SVG and share via expo-sharing - Alert.alert('Exportar SVG', 'Funcionalidade de exportação SVG será implementada na integração com o backend.'); + async function handleExport(): Promise { + const [widthStr, heightStr] = selectedSize.split('x'); + 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( + `/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( + `/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); @@ -174,8 +216,21 @@ export default function LabelScreen(): React.ReactElement { {/* CTA */} + + {(['pdf', 'svg'] as const).map((fmt) => ( + setSelectedFormat(fmt)} + style={[styles.formatChip, selectedFormat === fmt && styles.formatChipActive]} + > + + {fmt.toUpperCase()} + + + ))} +