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##"
"##,
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()}
+
+
+ ))}
+
}
onPress={handleExport}
/>
@@ -300,8 +355,27 @@ const styles = StyleSheet.create({
contentChipTextActive: { color: colors.accent },
footer: {
padding: spacing[5],
+ gap: spacing[3],
borderTopWidth: 1,
borderTopColor: colors.border,
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 },
});
diff --git a/mobile/app/(app)/inventory/[id]/edit.tsx b/mobile/app/(app)/inventory/[id]/edit.tsx
index 1ed6b04..14f213f 100644
--- a/mobile/app/(app)/inventory/[id]/edit.tsx
+++ b/mobile/app/(app)/inventory/[id]/edit.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import {
- View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
+ View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
@@ -50,7 +50,8 @@ export default function EditFilamentScreen(): React.ReactElement {
const [selectedMaterial, setSelectedMaterial] = useState((filament?.material as Material) ?? 'PLA');
const [selectedPresetId, setSelectedPresetId] = useState(filament?.spoolPresetId ?? systemPresets[0]?.id ?? null);
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({
resolver: zodResolver(schema),
@@ -86,31 +87,29 @@ export default function EditFilamentScreen(): React.ReactElement {
}
const handleOpenColorPicker = (): void => {
- if (isOpeningColorPicker) return;
- setIsOpeningColorPicker(true);
- Alert.prompt(
- 'Cor Customizada',
- 'Digite um código de cor hexadecimal (ex: #FF5733)',
- [
- { text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
- {
- text: 'Confirmar',
- onPress: (value) => {
- setIsOpeningColorPicker(false);
- if (!value) return;
- const normalized = normalizeHex(value.trim());
- if (isValidHex(normalized)) {
- setSelectedColor(normalized);
- setHexInput(normalized);
- } else {
- Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
- }
- },
- },
- ],
- 'plain-text',
- selectedColor,
- );
+ setColorInputValue(selectedColor);
+ setIsColorPickerOpen(true);
+ };
+
+ 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.');
+ }
+ };
+
+ const handleCancelColor = (): void => {
+ setIsColorPickerOpen(false);
+ setColorInputValue('');
};
const handleDelete = async (): Promise => {
@@ -183,7 +182,34 @@ export default function EditFilamentScreen(): React.ReactElement {
}
return (
-
+ <>
+
+
+
+
+ Código Hexadecimal
+
+
+
+ Cancelar
+
+
+ Confirmar
+
+
+
+
+
+
+
+
{/* Header */}
@@ -203,7 +229,7 @@ export default function EditFilamentScreen(): React.ReactElement {
{hexInput}
-
+
{PRESET_COLORS.map((c) => (
@@ -371,6 +397,7 @@ export default function EditFilamentScreen(): React.ReactElement {
/>
+ >
);
}
@@ -421,4 +448,15 @@ const styles = StyleSheet.create({
resultDot: { width: 8, height: 8, borderRadius: radius.full },
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 },
+ // 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 },
});
diff --git a/mobile/app/(app)/inventory/new.tsx b/mobile/app/(app)/inventory/new.tsx
index aa79eb3..2ec4765 100644
--- a/mobile/app/(app)/inventory/new.tsx
+++ b/mobile/app/(app)/inventory/new.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
- View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert,
+ View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Alert, Modal, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
@@ -49,7 +49,8 @@ export default function NewFilamentScreen(): React.ReactElement {
const [selectedMaterial, setSelectedMaterial] = useState('PLA');
const [selectedPresetId, setSelectedPresetId] = useState(systemPresets[0]?.id ?? null);
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
useEffect(() => {
@@ -69,33 +70,32 @@ export default function NewFilamentScreen(): React.ReactElement {
? calcNetWeight(Number(totalWeight), selectedPreset.spoolWeightG)
: 0;
const pct = Math.min(100, Math.round((netWeight / 1000) * 100));
-function handleOpenColorPicker(): void {
- if (isOpeningColorPicker) return;
- setIsOpeningColorPicker(true);
- Alert.prompt(
- 'Cor Customizada',
- 'Digite um código de cor hexadecimal (ex: #FF5733)',
- [
- { text: 'Cancelar', style: 'cancel', onPress: () => setIsOpeningColorPicker(false) },
- {
- text: 'Confirmar',
- onPress: (value) => {
- setIsOpeningColorPicker(false);
- if (!value) return;
- const normalized = normalizeHex(value.trim());
- if (isValidHex(normalized)) {
- setSelectedColor(normalized);
- setHexInput(normalized);
- } else {
- Alert.alert('Erro', 'Código hexadecimal inválido. Use o formato #RRGGBB ou #RGB.');
- }
- },
- },
- ],
- 'plain-text',
- selectedColor,
- );
- }
+
+ const handleOpenColorPicker = (): void => {
+ setColorInputValue(selectedColor);
+ setIsColorPickerOpen(true);
+ };
+
+ 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.');
+ }
+ };
+
+ const handleCancelColor = (): void => {
+ setIsColorPickerOpen(false);
+ setColorInputValue('');
+ };
async function onSubmit(data: FormData): Promise {
@@ -132,7 +132,34 @@ function handleOpenColorPicker(): void {
}
return (
- onPress={handleOpenColorPicker}
+ <>
+
+
+
+
+ Código Hexadecimal
+
+
+
+ Cancelar
+
+
+ Confirmar
+
+
+
+
+
+
+
+
{/* Header */}
@@ -150,7 +177,7 @@ function handleOpenColorPicker(): void {
{hexInput}
-
+
{PRESET_COLORS.map((c) => (
@@ -318,6 +345,7 @@ function handleOpenColorPicker(): void {
/>
+ >
);
}
@@ -365,4 +393,15 @@ const styles = StyleSheet.create({
resultDot: { width: 8, height: 8, borderRadius: radius.full },
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 },
+ // 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 },
});
diff --git a/mobile/assets/nfc.svg b/mobile/assets/nfc.svg
new file mode 100644
index 0000000..4d3ace0
--- /dev/null
+++ b/mobile/assets/nfc.svg
@@ -0,0 +1,8 @@
+
diff --git a/mobile/build-android-release.sh b/mobile/build-android-release.sh
new file mode 100755
index 0000000..1feceb2
--- /dev/null
+++ b/mobile/build-android-release.sh
@@ -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"
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index c63352c..90d175e 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -17,10 +17,12 @@
"expo": "~54.0.0",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
+ "expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
+ "expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
@@ -34,6 +36,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
+ "react-native-worklets": "^0.7.4",
"zod": "^3.23.8",
"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",
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -3870,7 +3872,7 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5593,7 +5595,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -6793,6 +6795,15 @@
"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": {
"version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
@@ -10826,26 +10837,6 @@
"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": {
"version": "3.2.2",
"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",
"integrity": "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "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",
"integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-create-class-features-plugin": "^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",
"integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@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",
"integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@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",
"integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^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",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^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",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
- "peer": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/mobile/package.json b/mobile/package.json
index 0623b66..cb6a948 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -20,10 +20,12 @@
"expo": "~54.0.0",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
+ "expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
+ "expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
@@ -37,6 +39,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
+ "react-native-worklets": "^0.7.4",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},